chrisjohnleah / sage-business-cloud-accounting-api
Framework-agnostic PHP SDK for the Sage Business Cloud Accounting API (v3.1), built on Saloon.
Package info
github.com/chrisjohnleah/sage-business-cloud-accounting-api
pkg:composer/chrisjohnleah/sage-business-cloud-accounting-api
Requires
- php: ^8.3
- saloonphp/pagination-plugin: ^2.0
- saloonphp/rate-limit-plugin: ^2.0
- saloonphp/saloon: ^4.0
Requires (Dev)
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- pestphp/pest: ^3.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
README
A modern, framework-agnostic PHP SDK for the Sage Business Cloud Accounting API (v3.1), built on Saloon. Typed responses, OAuth2 with rotating-refresh handling, X-Business targeting, automatic $next pagination, and rate-limit / 429 backoff — all baked in.
Using Laravel? Reach for the companion bridge
chrisjohnleah/sage-business-cloud-accounting-api-laravelfor a service provider, an Eloquent token store, andsage:connect/sage:synccommands.
Why this package
The Sage Accounting API has sharp edges that this SDK smooths over for you:
| Sage quirk | What the SDK does |
|---|---|
| 5-minute access tokens | Refreshes proactively before expiry |
| Rotating refresh tokens (single-use) | Surfaces the rotated token so you can persist it |
Mandatory X-Business header |
Applied automatically once a business is selected |
| No webhooks | First-class incremental polling via updated_or_created_since / deleted_since |
Body-based $next pagination |
Iterated transparently — foreach over every record |
| Rate limits (100/min per company) | Honours 429 Retry-After and backs off exponentially |
Requirements
- PHP 8.3+
- A Sage Developer app (OAuth2 client id + secret) — register at developerselfservice.sageone.com
Installation
composer require chrisjohnleah/sage-business-cloud-accounting-api
Quick start
use ChrisJohnLeah\SageAccounting\Auth\ArrayTokenStore; use ChrisJohnLeah\SageAccounting\Sage; use ChrisJohnLeah\SageAccounting\SageConnector; $connector = new SageConnector( clientId: getenv('SAGE_CLIENT_ID'), clientSecret: getenv('SAGE_CLIENT_SECRET'), redirectUri: 'https://your-app.test/oauth/sage/callback', scopes: ['readonly'], // or ['full_access'] ); // Bring your own persistence (DB, cache…) by implementing TokenStore. // ArrayTokenStore keeps tokens in memory — handy for scripts and tests. $sage = new Sage($connector, new ArrayTokenStore);
1. Send the user to Sage to authorise
$url = $sage->authorizationUrl(); // redirect the user here $state = $sage->generatedState(); // persist this (session/cache) for the callback
2. Handle the callback
$sage->exchangeCode($_GET['code'], $_GET['state'], $expectedState: $state); $sage->resolveBusiness(); // selects + remembers the business to target
3. Read data (auto-refreshes, auto-paginates)
// Every supplier bill updated since a timestamp — typed, across all pages. $bills = $sage->purchaseInvoices()->list([ 'updated_or_created_since' => '2026-05-01T00:00:00Z', ]); foreach ($bills as $invoice) { printf( "%s owes %.2f, due %s [%s]\n", $invoice->contactName, $invoice->outstandingAmount ?? 0.0, $invoice->dueDate?->format('Y-m-d') ?? 'n/a', $invoice->status?->displayedAs ?? 'unknown', ); } // Suppliers foreach ($sage->contacts()->list(['contact_type_id' => 'VENDOR']) as $contact) { echo $contact->name, ' <', $contact->email, ">\n"; }
Persisting tokens
Implement Contracts\TokenStore to store tokens wherever you like (the Laravel bridge ships an Eloquent one). Sage rotates the refresh token on every refresh, so your put() must always overwrite the previous record:
use ChrisJohnLeah\SageAccounting\Auth\StoredToken; use ChrisJohnLeah\SageAccounting\Contracts\TokenStore; final class MyTokenStore implements TokenStore { public function get(): ?StoredToken { /* load access/refresh/expiresAt/businessId */ } public function put(StoredToken $token): void { /* overwrite */ } public function forget(): void { /* delete */ } }
Coverage
The entire Sage Accounting v3.1 API. Every schema (200+) has a typed DTO and every operation (280+, including writes) has a request class — generated from the OpenAPI spec and verified by contract tests, so coverage can't silently regress.
Ergonomic, lazily-paginated resources are provided for the common entities (businesses(), contacts(), purchaseInvoices()); every other endpoint is reachable by sending its generated request through $sage->connector():
use ChrisJohnLeah\SageAccounting\Requests\LedgerAccounts\GetLedgerAccounts; use ChrisJohnLeah\SageAccounting\Requests\Contacts\PostContacts; $ledgers = $sage->connector()->send(new GetLedgerAccounts(['attributes' => 'all']))->dto(); $created = $sage->connector()->send(new PostContacts(['contact' => [/* ... */]]))->dto();
Regenerating from the spec
php tools/generate.php # re-reads resources/openapi/sage-accounting-3.1.0.json
The hand-crafted core (connector, OAuth, paginator, client, Reference/Paginated) is never touched — only the leaf DTOs and request classes are generated.
Testing
composer test # Pest composer analyse # PHPStan (max) composer lint # Pint --test composer check # all three
Tests never hit the network — every request is faked with Saloon's MockClient.
Contributing
Issues and PRs welcome — see CONTRIBUTING.md. Please report security issues privately per SECURITY.md.
Licence
MIT © Chris John Leah. See LICENSE.
Not affiliated with or endorsed by The Sage Group plc. "Sage" is a trademark of its respective owner.