saloonphp/saloon
Saloon is a PHP package for building beautiful, class-based API integrations. Define connectors and requests, then handle authentication, caching, retries, pagination, and testing through a clean, framework-agnostic API that works with Laravel or any PHP project.
Installation
composer require saloonphp/saloon
In Laravel the Saloon\Laravel\SaloonServiceProvider is auto-discovered — no manual config/app.php entry needed. Publish the config if you want to tweak defaults:
php artisan vendor:publish --tag=saloon
First Connector — one class per third-party API, centralising base URL, auth and headers:
namespace App\Http\Integrations\Stripe;
use Saloon\Http\Connector;
use Saloon\Traits\Plugins\AcceptsJson;
use Saloon\Traits\Plugins\HasApiVersion;
class StripeConnector extends Connector
{
use AcceptsJson;
use HasApiVersion;
public function resolveBaseUrl(): string
{
return 'https://api.stripe.com/v1';
}
/** New in v4.5.0 — version is applied to every request from this connector. */
protected function resolveApiVersion(): string
{
return config('services.stripe.version', '2024-06-20');
}
protected function defaultHeaders(): array
{
return [
'Authorization' => 'Bearer ' . config('services.stripe.secret'),
];
}
}
First Request — a Request is a value object describing one endpoint:
namespace App\Http\Integrations\Stripe\Requests;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetCustomerRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(protected string $customerId) {}
public function resolveEndpoint(): string
{
return '/customers/' . $this->customerId;
}
}
Send It
$connector = app(StripeConnector::class);
$response = $connector->send(new GetCustomerRequest('cus_123'));
$customer = $response->json(); // array
$response->status(); // int
$response->throw(); // raise on 4xx/5xx
The most common reason to reach for HasApiVersion is a provider that ships dated versions (Stripe, Shopify, Notion). Add the trait to the connector, resolve the version from config/env, and the header is attached to every request — no per-request boilerplate, and QA/staging can point at an older version via .env without touching code.
HasApiVersion.Saloon\Laravel\SaloonServiceProvider (container bindings, mocking helpers in tests).app/Http/Integrations/* first; Saloon's own docs use that convention and it keeps connectors out of App\Services.One connector per API; requests stay dumb. Keep credentials in config/services.php, never in request classes.
class GitHubConnector extends Connector
{
use AcceptsJson, HasTimeout;
public function resolveBaseUrl(): string
{
return 'https://api.github.com';
}
protected function defaultAuth(): Authenticator
{
return new BearerAuthenticator(config('services.github.token'));
}
protected function defaultHeaders(): array
{
return ['Accept' => 'application/vnd.github+json'];
}
}
Resolve it from the container so tests and Octane share one instance:
$this->app->singleton(StripeConnector::class);
$stripe = app(StripeConnector::class);
Anything declared on the connector can be overridden per request: resolveEndpoint(), $method, defaultHeaders(), defaultAuth(), defaultBody(), $tries. Use this for the one endpoint that needs a different auth scheme or a longer timeout.
class UploadDocumentRequest extends Request
{
protected Method $method = Method::POST;
public function resolveEndpoint(): string
{
return '/documents';
}
protected function defaultBody(): array
{
return ['name' => $this->name, 'contents' => base64_encode($this->bytes)];
}
public function resolveApiVersion(): string // per-request version pin
{
return '2024-01-01';
}
}
HasApiVersion (v4.5.0)resolveApiVersion() on individual requests when an endpoint lags behind.services.stripe.version) so it's a one-line .env change, not a deploy.2024-06-20, 2023-10-16, v2). Don't normalise or add prefixes.// Global default on the connector, one endpoint pinned back
class ListInvoicesRequest extends Request
{
public function resolveApiVersion(): string
{
return '2023-08-16'; // legacy endpoint not yet migrated
}
}
Test the version is actually sent — assert it on the recorded request rather than trusting the trait:
$connector->withMockClient($mock = new MockClient([
ListInvoicesRequest::class => MockResponse::make(['data' => []]),
]));
$connector->send(new ListInvoicesRequest());
expect($mock->getLastRequest()->headers()->get('Api-Version'))->toBe('2023-08-16');
Built-in authenticators: BearerAuthenticator, BasicAuthenticator, HeaderAuthenticator, QueryAuthenticator, CertificateAuthenticator, and MultiAuthenticator for combining them.
For OAuth2 prefer the grant traits over hand-rolling a token cycle:
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\OAuth2\ClientCredentialsGrant;
class GitHubConnector extends Connector
{
use ClientCredentialsGrant;
public function resolveBaseUrl(): string { return 'https://api.github.com'; }
protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make()
->setClientId(config('services.github.client_id'))
->setClientSecret(config('services.github.client_secret'))
->setTokenEndpoint('/login/oauth/access_token');
}
}
Persist tokens in the cache (Saloon\Contracts\AccessTokenRepository / RefreshTokenRepository) so queue workers and Octane workers reuse them instead of re-authenticating per job.
Instead of middleware, compose traits on the connector:
use Saloon\Traits\Plugins\AcceptsJson;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
use Saloon\Traits\Plugins\HasTimeout;
use Saloon\Traits\Plugins\HasApiVersion;
class StripeConnector extends Connector
{
use AcceptsJson, AlwaysThrowOnErrors, HasApiVersion, HasTimeout;
protected int $tries = 3;
protected int $retryInterval = 500; // ms
protected bool $throwOnMaxTries = true;
protected int $connectTimeout = 10;
protected int $requestTimeout = 30;
public function handleRetry(FatalRequestException|RequestException $exception, Request $request): bool
{
return $exception instanceof FatalRequestException // only network blips
|| ($exception instanceof RequestException && $exception->getResponse()->serverError());
}
}
For heavy GET traffic, saloonphp/cache-plugin gives you per-request response caching with TTLs.
MockClientuse Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
it('lists invoices for the pinned version', function () {
$mock = new MockClient([
ListInvoicesRequest::class => MockResponse::make(['data' => [['id' => 'in_1']]], 200),
]);
$connector = (new StripeConnector)->withMockClient($mock);
$response = $connector->send(new ListInvoicesRequest);
expect($response->json('data.0.id'))->toBe('in_1');
$mock->assertSent(ListInvoicesRequest::class);
});
MockClient::global([...]) covers a whole suite; always assert what was sent as well as what came back — version headers, auth headers and body shape are exactly where integrations silently rot.
use Saloon\Exceptions\Request\ClientException;
use Saloon\Exceptions\Request\ServerException;
use Saloon\Exceptions\Request\FatalRequestException;
try {
$response = $connector->send(new CreateChargeRequest($payload));
} catch (ClientException $e) { // 4xx
report($e); // log full request/response
throw new PaymentFailedException($e->getResponse()->json('error.message'));
} catch (ServerException|FatalRequestException $e) {
// already retried by the connector; let it bubble
throw $e;
}
Convert provider errors into your own domain exceptions at the connector/request boundary — callers shouldn't know about Stripe's envelope shape. Use $response->failed(), $response->clientError(), $response->serverError() for non-throwing flows.
Build request objects at runtime from user input or DB rows; keep the class list small and explicit.
$connector->send(new GetCustomerRequest($order->stripe_customer_id));
Wrap API calls in queued jobs (ShouldQueue) with the connector resolved from the container — retries belong to the queue, while Saloon's $tries handles transient HTTP failures inside a single attempt. Keep them low so you don't multiply attempts.
HasApiVersion needs an implementation. Adding the trait without defining resolveApiVersion() (or a sensible default) will blow up at send time. Define it once on the connector and override only where legacy endpoints demand it.?version= or /v2/, the trait alone won't do it; implement the placement in the request instead.$response->status() is a method, not a property. A lot of copy-pasted snippets get this wrong.AlwaysThrowOnErrors or an explicit $response->throw(), a 500 is just an array-less response that fails downstream in confusing ways.handleRetry() if you need it; but never retry non-idempotent POSTs without an idempotency key header.resolveBaseUrl() should have no trailing slash, resolveEndpoint() should start with one — otherwise you get //customers.MockClient left wired in a service provider will happily return fixtures in production. Bind it in TestCase, not AppServiceProvider.Request-Id, x-request-id) in your exception handler — it's the difference between a 10-minute and a 2-hour support ticket.defaultHeaders(), defaultAuth(), defaultQuery(), defaultBody(), handleRetry(), plus plugin traits for caching (saloonphp/cache-plugin), rate limiting (saloonphp/rate-limit-plugin) and logging (saloonphp/laravel-plugin HTTP client events). Prefer a trait over copying boilerplate into every connector.How can I help you explore Laravel packages today?