Search by

hampel / saasu-api-laravel

hampel

Laravel service provider, manager and facade for the Saasu accounting API client, with cached OAuth tokens, a rate limit shared across workers, and Http::fake() support

Package info

github.com/hampel/saasu-api-laravel

pkg:composer/hampel/saasu-api-laravel

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-09-14 22:54 UTC

This package is auto-updated.

Last update: 2026-09-15 01:55:30 UTC


README

Tests Latest Version on Packagist Total Downloads Open Issues License

By Simon Hampel

Laravel integration for hampel/saasu-api — a service provider, a manager for named connections, and a facade.

The Saasu API is rationed: one request a second, a daily quota, and a 24-hour block on the whole file — every integration using it — once the quota is exceeded. The first two things this package adds are about that:

  • OAuth tokens are cached. Every PHP process shares one token per login, rather than logging in again on every web request and every worker restart, each time spending quota.
  • The rate limit is shared. Several queue workers keep to one request a second per file between them, not one each.
  • Named connections. A file id and a credential per connection, a default, and Saasu::client('name') to reach one.
  • Http::fake() sees the API client's traffic. Every request goes through Laravel's own handler stack, so Http::fake(), Http::assertSent() and Http::preventStrayRequests() all work.

The request building, status mapping and exception hierarchy are the core package's, untouched.

Requirements

PHP 8.3 or later, and Laravel 12 or 13.

Laravel Zero works too, with the HTTP component installed (php <app> app:install http). Laravel binds Illuminate\Http\Client\Factory as a singleton in FoundationServiceProvider, which a Laravel Zero application does not register; unbound, Http::fake() silently fails to intercept and the request reaches the real API. This package binds one when nothing else has, so the behaviour is the same on both.

Installation

composer require hampel/saasu-api-laravel

In a Laravel application the provider and the Saasu alias are discovered automatically. Publish the config file if you want to edit it:

php artisan vendor:publish --tag=saasu-config

Laravel Zero does not run package discovery, so there the provider has to be listed by hand, in config/app.php:

'providers' => [
    Hampel\Saasu\Api\Laravel\SaasuServiceProvider::class,
],

The global Saasu alias is not registered either. Import the facade class — use Hampel\Saasu\Api\Laravel\Facades\Saasu; — or inject SaasuManager.

vendor:publish --tag=saasu-config works in Laravel Zero once the provider is listed, though list does not show it: Laravel Zero hides the command rather than removing it.

Configuration

SAASU_FILE_ID=12345
SAASU_USERNAME=api@example.com
SAASU_PASSWORD=your-password

The shipped config/saasu.php defines one connection called main. Add more by naming them — one per file, or one per login:

'default' => 'main',

'connections' => [
    'main' => [
        'file_id' => env('SAASU_FILE_ID'),
        'username' => env('SAASU_USERNAME'),
        'password' => env('SAASU_PASSWORD'),
    ],

    'subsidiary' => [
        'file_id' => env('SUBSIDIARY_SAASU_FILE_ID'),
        'username' => env('SAASU_USERNAME'),
        'password' => env('SAASU_PASSWORD'),
    ],
],

Connections sharing a login share its token: it is cached under a key derived from the username.

An application config file named saasu.php replaces these settings key by key. Laravel merges a package's configuration shallowly, and the application's file wins for every top-level key it defines. So an application that already keeps its own settings in config/saasu.php — a timeout meaning something else, say — silently changes this package's settings of the same name. Publish this file and edit it, or name your own settings file something else.

Credentials

A connection uses OAuth whenever it has a username and password, and its access key only when that is the only credential it has.

the connection has it uses
username and password OAuth
username, password and access_key OAuth — the key is ignored
access_key only the web services access key
only one of username and password nothing — refused, even with an access_key
none of them nothing — refused
  • OAuth is Saasu's preferred scheme. The login must not have two-factor authentication on, because nothing unattended can answer the code Saasu texts. A login created for the integration is better than a person's.
  • The access key is Saasu's legacy scheme, from Settings, Web Services. It travels in the URL of every request, where proxies and access logs can see it, and it belongs to one file, so a connection using it must have a file_id.
  • Half a login is refused rather than ignored, so an unset SAASU_PASSWORD cannot quietly move a connection that also has an access key onto the legacy scheme.

Every refusal happens when the client is built, before a request is spent, and raises Hampel\Saasu\Api\Laravel\Exception\InvalidConfiguration. An empty string counts as unset.

A connection without a file_id is allowed with OAuth, for listing the files a login can reach with Saasu::client('name')->files()->all().

Rate limit

'throttle' => env('SAASU_THROTTLE', 'cache'),   // cache, process or none
'cache_store' => env('SAASU_CACHE_STORE'),      // null uses the default store

Every request waits for its turn, logins included. Saasu allows one request a second per file.

throttle keeps to one request a second
cache per file, across every process sharing the cache store — the default
process per file, within each PHP process only
none not at all — for a test suite

The cache mode reserves each process a slot under a short cache lock, then sleeps until it, so workers take turns in the order they arrived. It needs a store with atomic locks — redis, database, memcached, dynamodb, file or array — and is refused on one without. Workers on several hosts need clocks that agree. The null store defeats it silently: it remembers nothing, so nothing waits.

A client moved to another file keeps its throttle. Saasu::withFileId(67890) shares the default connection's throttle, and so waits for the default connection's file, not file 67890. Where several workers use a second file, configure a connection for it.

Tokens

An OAuth token is cached in the same store, without an expiry, under a key derived from the username. Its refresh token lasts twelve months, and refreshing costs the same one request as logging in, so the grant is kept rather than left to expire. Two workers that find a token expired at the same moment both refresh it, and both succeed.

A cached token is a credential, stored unencrypted, as the cache stores anything else. Point cache_store at a store no more widely readable than your .env file.

To keep tokens somewhere else, bind your own Hampel\Saasu\Api\Authentication\TokenStore:

use Hampel\Saasu\Api\Laravel\SaasuServiceProvider;

$this->app->singleton(SaasuServiceProvider::TOKEN_STORE, fn () => new MyTokenStore());

Page size and base URI

'page_size' => env('SAASU_PAGE_SIZE'),   // 1 to 100; null uses 100
'base_uri' => env('SAASU_API_URL'),      // null uses Saasu's own host

Both are shared by every connection and validated when a client is built. The page size defaults to Saasu's maximum rather than its own default of 25, because every page is a request against the quota. base_uri exists for a recorded fixture served locally and for an outbound proxy that terminates the connection.

There is no request budget setting. Clients are kept for the life of the process, so a budget configured here would count every request a queue worker had sent since it started.

Transport

'timeout' => 30,
'connect_timeout' => 5,

Applied to every request, alongside any Http::globalRequestMiddleware() the application has configured and the transport settings from Http::globalOptions() — a proxy, a CA bundle or client certificate, the protocol version, curl options. Global options that would change the request itself are not applied: headers, auth, query and the body options would overwrite what the core package built, including its credential and file id. The timeout does not include the time a request waits under the rate limit.

Usage

The facade reaches the default connection directly:

use Hampel\Saasu\Api\Laravel\Facades\Saasu;

$file = Saasu::verify();                    // does this credential reach this file?

$contacts = Saasu::contacts()->list();      // the first page of up to 100
$contact = Saasu::contacts()->get(54353);

Name a connection to reach another:

$invoices = Saasu::client('subsidiary')->invoices()->list();

It is client() and not connection(). Saasu::connection() is the core package's transport, and the manager forwards unknown calls to the default client, so a method on both would mean different things depending on which class you thought you were calling.

Everything past that point is the core package — see its documentation for the endpoints, entities, filters, pagination and updating a record.

Inject the manager where a facade is not wanted:

use Hampel\Saasu\Api\Laravel\SaasuManager;

public function __construct(private readonly SaasuManager $saasu) {}

$this->saasu->client('subsidiary')->contacts()->list();

Errors

The core package's exceptions arrive untouched:

use Hampel\Saasu\Api\Exception\ConcurrencyException;
use Hampel\Saasu\Api\Exception\NotAuthenticatedException;
use Hampel\Saasu\Api\Exception\NotFoundException;

try {
    $contact = Saasu::contacts()->get($id);
} catch (NotFoundException $e) {
    // no such contact in this file
} catch (NotAuthenticatedException $e) {
    // the login or key is no good. A configuration error, not an empty result.
}

UnknownConnection and InvalidConfiguration extend the core package's SaasuException, so an application already catching that catches these too.

Testing

Set SAASU_THROTTLE=none in phpunit.xml, or every faked request after the first waits a second:

<env name="SAASU_THROTTLE" value="none"/>

Then fake the API with the vocabulary the rest of your suite already uses:

use Illuminate\Support\Facades\Http;

Http::preventStrayRequests();

Http::fake([
    'api.saasu.com/authorisation/*' => Http::response([
        'access_token' => 'test-token',
        'expires_in' => 10800,
    ]),
    'api.saasu.com/Contact/*' => Http::response([
        'Id' => 54353,
        'GivenName' => 'Joe',
    ]),
]);

$contact = Saasu::contacts()->get(54353);

Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer test-token'));

The package's real code path runs; only the socket is replaced. So a faked 404 still arrives as NotFoundException, and a faked 200 whose body is HTML still arrives as MalformedResponseException.

  • An OAuth connection logs in first. Fake api.saasu.com/authorisation/*, and list it before any catch-all such as api.saasu.com/*: Http::fake() answers from the first pattern that matches. Once a token is cached, later requests in the same test do not log in again.
  • Give every fake a body. Http::fake() with no arguments answers every request with an empty 200, which the core package refuses for a read.
  • Order does not matter. Faking after the client has been resolved works, and so does Http::swap(new Factory) to start a test from a clean set of fakes: the transport looks the factory up at the moment of sending.

Replace the transport entirely by binding saasu.http_client — how an application with its own outbound HTTP policy makes this package use it:

use Hampel\Saasu\Api\Laravel\SaasuServiceProvider;

$this->app->singleton(SaasuServiceProvider::HTTP_CLIENT, fn () => $myPsr18Client);

The package binds that key and saasu.token_store only if nothing has already, so an override works from any service provider, whether it registers before or after this package's — including AppServiceProvider in a Laravel Zero config/app.php, where it is listed first.

This package does not use a Psr\Http\Client\ClientInterface binding, yours or another package's, and does not bind that key itself. It is one key shared by every package that uses it, so an application with several API integrations installed would otherwise get whichever registered last.

What Laravel's HTTP events see

RequestSending fires; ResponseReceived and ConnectionFailed do not. Laravel raises the first from inside the handler stack this package sends through, and the other two from a layer above it. So Telescope's HTTP client watcher, which listens for ResponseReceived, will not show this traffic. The core package logs every request at debug through PSR-3, and an error response at error, with any access key removed from the URI.

Versioning

hampel/saasu-api is 0.x, so its public API can change in a minor release; this package constrains it at ^0.1 and expects to bump.

License

MIT. See LICENSE.md.