sammyjo20/saloon
Saloon is a Laravel/PHP package for building elegant, robust API integrations. It provides an object-oriented, class-based way to define requests and responses, with built-in support for authentication, caching, retries, rate limiting, and testing.
Saloon is the HTTP integration layer you reach for instead of scattering Http::get() calls across jobs and controllers. This release (v4.5.0) is additive — a single new feature, no breaks — so existing integrations keep working untouched.
Minimal setup:
composer require saloonphp/saloon
php artisan vendor:publish --tag=saloon-config # optional, only if you need global defaults
Scaffold your first integration rather than hand-writing classes:
php artisan saloon:connector GitHubConnector
php artisan saloon:request GetUserRequest
Then the standard three-line flow:
$connector = new GitHubConnector();
$response = $connector->send(new GetUserRequest($username));
$user = $response->dto();
Where to look first, in order:
resolveEndpoint(), method(), and any per-request body/query.docs.saloon.dev — the "Connectors → Authentication", "Requests → Data Transfer Objects", and "Features → Middleware" pages cover ~80% of day-to-day needs.First real use case: a read-only integration against a token-authenticated REST API. Define resolveBaseUrl(), call withTokenAuth(config('services.x.token')) in defaultAuth(), and return a DTO from createDtoFromResponse(). That's a complete, testable client in about 30 lines.
What's new in v4.5.0: the HasApiVersion trait, which gives API versioning a first-class home on the connector instead of you string-concatenating versions into resolveBaseUrl(). See below.
The three-layer split. Connectors (transport, auth, defaults) → Requests (endpoint, verb, payload) → Response/DTOs (parsing). Resist the urge to put endpoint logic in the connector or auth in the request; the package's whole ergonomic advantage comes from keeping those seams clean. For larger APIs, group Requests into subfolders per resource (Requests/Users/, Requests/Repos/) and add a thin Api facade class that wraps the connector for callers who shouldn't know about Saloon at all.
DTO hydration as the default. Implement createDtoFromResponse(Response $response): YourDto on each Request and always consume ->dto(). It centralises null-safety and shape assumptions in one place, and it means your application code never touches ->json('data.attributes.name'). Use Data::from($response->json()) if you're on spatie/laravel-data.
Auth belongs in defaultAuth(). Token, basic, digest, and OAuth2 flows are all supported. For OAuth2, the token can be cached so you're not re-authenticating per request:
protected function defaultAuth(): Authenticator
{
return new OAuth2(
grantType: GrantType::CLIENT_CREDENTIALS,
// ...
accessToken: $this->cache->remember('x-token', 3500, fn () => /* fetch */),
);
}
Middleware is where cross-cutting concerns go. Chain these on the connector or per-request:
$connector->middleware()->onRequest(new AlwaysThrowOnErrors());
$connector->middleware()->onResponse(new Retry(maxAttempts: 3, interval: 1000));
$connector->middleware()->onRequest(new RateLimit(limit: 60, interval: 60));
$connector->middleware()->onRequest(new CacheFor(now()->addHour()));
Retry supports exponential backoff and a when() closure; wire that to a ConnectException/5xx predicate so you don't retry 4xx validation errors.
Concurrency with pool(). For fan-out workloads (fetching 50 repos, enriching 200 records) use $connector->pool(fn (Pool $pool) => ...) and process responses in the pool's response handler. This is the single biggest performance win over sequential send() calls, but combine it deliberately with RateLimit — the limiter is per-process and won't coordinate across queue workers.
Testing with MockClient. Never hit a real API in tests. Attach a MockClient to the connector, queue MockResponses (including fixture files), then assertSent(). Keep fixtures in tests/Fixtures/ and build one MockResponse per scenario (200, 429, 500, malformed).
API versioning with the new HasApiVersion trait (v4.5.0). Previously, supporting two API versions meant either duplicating connectors or mutating the base URL by hand. The trait lets a connector declare its version and have Saloon resolve it consistently:
use Saloon\Http\Connector;
use Saloon\Traits\HasApiVersion;
class AcmeConnector extends Connector
{
use HasApiVersion;
public function resolveBaseUrl(): string
{
return 'https://api.acme.com';
}
}
Practical patterns this unlocks:
AcmeV2Connector extends AcmeConnector overriding the version, sharing all auth and middleware. Callers inject whichever connector they need; requests stay identical.config('services.acme.version') and set it in the constructor, so a feature flag or A/B rollout flips versions without a deploy of request classes.Accept-Version / API-Version) — use defaultHeaders() and let the trait own the value, instead of hard-coding strings in twenty requests.MockClient tests over an array of versions to prove request shapes don't silently diverge.Because this is a brand-new trait, verify the exact property/method names against the PR or docs page before committing to it — if the surface doesn't fit (e.g. you need per-request overrides), the fallback is unchanged: override resolveBaseUrl() in a subclass.
Base URL resolution order. resolveBaseUrl() runs at construction; anything that mutates state afterwards (including a version setter, if the trait works that way) may be too late. If your version suddenly isn't applied, dump $connector->resolveBaseUrl() before sending. Watch trailing slashes — .../v2/ + /users yields a double slash, which some gateways 404 on.
MockClient must be attached before send(). Setting it after is a no-op and your test quietly hits the network. Similarly, assertSent() fails with a confusing message if the request was sent through a different connector instance — bind connectors as singletons in a service provider so tests and callers share one instance.
dto() returns null on an empty body and throws if createDtoFromResponse() isn't implemented. Both are silent-until-runtime failures. If an endpoint can legitimately 204, your callers must handle null; type your return accordingly.
Error handling is opt-in. By default a 500 response is returned, not thrown. Add AlwaysThrowOnErrors middleware globally, or call $response->throw() at the call site. Mixing the two leads to double-wrapped exceptions in logs.
Timeouts are not configured by default. $connector->setTimeout(10) (and connectTimeout) on the connector, or you'll inherit your PHP/HTTP client default — often 0 (infinite) — and hang queue workers on a dead upstream.
Retry + non-idempotent verbs. Retry will happily re-POST. Scope it with ->when() or attach it only to GET requests. Same caution for concurrency: pool() rejects on the first exception by default, so a single 500 can discard in-flight work.
Response bodies are streams, not strings. $response->body() is a Stringable/stream wrapper; use ->json(), ->body() cast, or ->collect(). Don't json_decode($response->body()) blindly — check ->failed() first.
Debugging. The most reliable first move is $response->getPsrRequest() / getPsrResponse() to inspect the actual wire request (headers included — useful for confirming that version header). For "why is this request shaped wrong", implement __toString() or use Laravel's HTTP client event listener hooked through a custom Sender; for auth problems, temporarily dd($connector->getAuthenticator()) — expired OAuth tokens are the usual culprit.
Extension points worth knowing. Custom Sender (swap the underlying HTTP client), custom Authenticator, custom middleware implementing RequestMiddleware/ResponseMiddleware, and Connector subclassing for shared defaults across an SDK. The trait added here is itself an example of the intended pattern: small, composable traits on connectors rather than a monolithic base class.
Config quirks. The published saloon.php is optional and mostly empty; resist stuffing app config into it. Keep tokens in config/services.php via env, and remember config() is cached in production — restart/rebuild after env changes.
How can I help you explore Laravel packages today?