hampel / saasu-api-laravel
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
Requires
- php: >=8.3
- ext-ctype: *
- guzzlehttp/guzzle: ^7.8|^8.0
- guzzlehttp/psr7: ^2.0|^3.0
- hampel/saasu-api: ^0.1
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/log: ^1.1|^2.0|^3.0
Requires (Dev)
- larastan/larastan: ^3.4.2
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- phpstan/phpstan: ^2.1.22
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-15 01:55:30 UTC
README
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, soHttp::fake(),Http::assertSent()andHttp::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_PASSWORDcannot 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 asapi.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.