chrisjohnleah/sage-business-cloud-accounting-api

Framework-agnostic PHP SDK for the Sage Business Cloud Accounting API (v3.1), built on Saloon.

Maintainers

Package info

github.com/chrisjohnleah/sage-business-cloud-accounting-api

pkg:composer/chrisjohnleah/sage-business-cloud-accounting-api

Statistics

Installs: 10

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.2 2026-05-30 15:09 UTC

This package is auto-updated.

Last update: 2026-05-30 15:09:40 UTC


README

CI Packagist Version Total Downloads PHP Version License: MIT

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-laravel for a service provider, an Eloquent token store, and sage:connect / sage:sync commands.

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

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.