- Introduction
- Installation
- Generating Integrations
- Defining Connectors
- Defining Requests
- Configuring Requests
- Middleware
- Responses
- OAuth 2
- Concurrent Requests
- Caching
- API Pagination
- Rate Limiting
- Multi-Tenant Integrations
- Telescope
- Events
- Macros
- Testing
- Publishing Configuration and Stubs
- Differences From Saloon
- Credits
Saloon provides an expressive, class-based approach to building integrations with external APIs. A connector describes an integration's stable configuration, while request classes describe individual endpoints. This makes integrations easier to reuse, test, and distribute as SDKs.
Hypervel's Saloon package uses the HTTP client directly. As a result, Saloon requests share Hypervel's response API, named HTTP connections, fakes, events, Telescope support, and coroutine-native networking:
use App\Http\Integrations\GitHub\GitHubConnector; use App\Http\Integrations\GitHub\Requests\GetUser; $github = app(GitHubConnector::class); $response = $github->send(new GetUser('hypervel')); return $response->json('name');
Connectors are reusable definitions and may safely be injected into worker-shared services when their constructor values are stable. Each request and pending request belongs to one operation. Values that vary between requests, such as tenant credentials, tracing headers, cache controls, and retry behavior, should be applied to the request or to an explicitly constructed connector for that operation.
You may install Saloon using the Composer package manager:
composer require hypervel/saloon
Saloon's service provider and facade are discovered automatically.
Saloon includes Artisan commands for generating connectors, requests, authenticators, plugins, and custom responses. Generated integrations are stored in app/Http/Integrations by default:
php artisan saloon:connector GitHub GitHubConnector php artisan saloon:request GitHub GetUser --method=GET php artisan saloon:auth GitHub GitHubAuthenticator php artisan saloon:plugin GitHub AddsRequestId php artisan saloon:response GitHub GitHubResponse
The connector generator's --oauth option generates an OAuth 2 authorization-code connector:
php artisan saloon:connector GitHub GitHubConnector --oauth
Like Hypervel's other generator commands, each generator supports --target-path and --target-namespace. You may inspect the generated integrations using the saloon:list command:
php artisan saloon:list
Each integration begins with a connector. At a minimum, your connector must extend Hypervel\Saloon\Http\Connector and define its base URL:
<?php declare(strict_types=1); namespace App\Http\Integrations\GitHub; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Traits\Plugins\AcceptsJson; class GitHubConnector extends Connector { use AcceptsJson; public function resolveBaseUrl(): string { return 'https://api.github.com'; } }
The AcceptsJson plugin adds an Accept: application/json header to each operation unless the request already provides one.
Connectors are regular classes, so fixed integration configuration may be supplied through constructor injection. For example, a connector may receive a token from your application's services configuration:
use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Http\Auth\TokenAuthenticator; class GitHubConnector extends Connector { public function __construct( private readonly string $token, ) { } public function resolveBaseUrl(): string { return 'https://api.github.com'; } protected function defaultAuth(): ?Authenticator { return new TokenAuthenticator($this->token); } }
You may bind the configured connector in a service provider:
use App\Http\Integrations\GitHub\GitHubConnector; $this->app->singleton(GitHubConnector::class, function () { return new GitHubConnector(config()->string('services.github.token')); });
Do not change a reused connector while handling a request. If a credential or endpoint belongs to the current tenant, construct a connector with that tenant's values for the operation instead. Multi-tenant integrations are discussed later in this guide.
You may define default headers, query parameters, Guzzle options, authentication, delays, retry policies, and request bodies using protected connector methods:
use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Http\Auth\TokenAuthenticator; protected function defaultHeaders(): array { return ['X-Api-Version' => '2026-01-01']; } protected function defaultQuery(): array { return ['locale' => 'en']; } protected function defaultOptions(): array { return ['allow_redirects' => false]; } protected function defaultAuth(): ?Authenticator { return new TokenAuthenticator($this->token); }
For defaults that require the complete operation, override the connector's boot method. The method receives a fresh pending request for each send:
use Hypervel\Saloon\Http\PendingRequest; public function boot(PendingRequest $pendingRequest): void { $pendingRequest ->withHeader('X-Request-Source', 'billing') ->timeout(15) ->withTelescopeTags(['github']); }
A connector may serve as an SDK's entry point by offering methods that send its requests:
use App\Http\Integrations\GitHub\Requests\GetUser; use Hypervel\Saloon\Http\Response; public function user(string $username): Response { return $this->send(new GetUser($username)); }
When an integration contains many endpoints, you may group related requests into resource classes. Extend BaseResource and use the @extends annotation to specify your connector type:
use App\Http\Integrations\GitHub\GitHubConnector; use App\Http\Integrations\GitHub\Requests\GetRepository; use Hypervel\Saloon\Http\BaseResource; use Hypervel\Saloon\Http\Response; /** @extends BaseResource<GitHubConnector> */ class RepositoryResource extends BaseResource { public function get(string $owner, string $repository): Response { return $this->connector->send(new GetRepository($owner, $repository)); } }
Expose the resource from your connector:
public function repositories(): RepositoryResource { return new RepositoryResource($this); }
You may then call the resource from your application:
$response = $github->repositories()->get('hypervel', 'components');
Your resource may access the connector through its protected, readonly $connector property. The annotation allows your editor and static analysis tools to recognize the connector's methods and response types.
Saloon registers one named Hypervel HTTP connection, named saloon by default. Synchronous requests through this connection reuse the low-level transport, allowing cURL to retain keep-alive sockets, DNS information, and TLS sessions. Each operation still receives its own pending request, client, middleware stack, and cookie jar.
The saloon connection uses a 10-second connection timeout and a 30-second request timeout. Like other HTTP client requests, Saloon requests require TLS 1.2 or later unless the connection sets another crypto_method option. You may publish the configuration file to change its options. A connector may also select another connection that your application registers during worker boot:
use Hypervel\Support\Facades\Http; Http::registerConnection('github', [ 'connect_timeout' => 5, 'timeout' => 60, ]);
public function resolveHttpConnection(): ?string { return 'github'; }
A connection used by Saloon may not contain options that shape the request, such as headers, query, cookies, json, and auth, because Saloon builds those from the connector and request. Define them on the connector instead. Saloon requests also receive the HTTP client's global options and global middleware.
Connection names must come from a fixed, bounded set. Do not create a connection name from a tenant ID, hostname, or credential, since registered connections and their handlers remain in memory for the worker lifetime. One fixed connection may safely send requests to many tenant-selected hosts.
Connection options do not provide an aggregate connection cap. The unsafe Guzzle max_host_connections and max_total_connections options are rejected. Bound each concurrent operation with a Saloon pool, and use rate limits to control request rates. Saloon does not use object pooling because each request needs its own client, middleware stack, and cookie jar.
A request class defines an API endpoint and HTTP method:
<?php declare(strict_types=1); namespace App\Http\Integrations\GitHub\Requests; use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Request; class GetUser extends Request { protected Method $method = Method::GET; public function __construct( public readonly string $username, ) { } public function resolveEndpoint(): string { return "/users/{$this->username}"; } }
Requests are mutable while you prepare one operation. You may create them with new or the convenient make method:
$request = GetUser::make('hypervel');
Requests may also be resolved from the service container, which creates a fresh request on each resolution and injects its constructor dependencies. Pass any other constructor values as parameters to the container's make method.
The Method enum provides GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT, TRACE, and QUERY cases. QUERY requests may contain a body and are useful for safe, idempotent queries that are too large for a URI:
use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Traits\Body\HasJsonBody; class SearchUsers extends Request { use HasJsonBody; protected Method $method = Method::QUERY; public function resolveEndpoint(): string { return '/users/search'; } }
Pass the request to a connector's send method:
$response = $github->send( GetUser::make('hypervel')->withHeader('X-Trace-ID', $traceId), );
The returned Hypervel\Saloon\Http\Response extends Hypervel's normal HTTP response, so the same JSON, header, status, exception, and PSR-7 methods are available.
To inspect an outgoing request without sending it, pass the request to the connector's createPendingRequest method. The pending request has run its plugins, authenticator, boot hooks, and request middleware:
$pendingRequest = $github->createPendingRequest(GetUser::make('hypervel')); $pendingRequest->headers(); $pendingRequest->uri();
The URL and body are finalized, and fake responses are matched, only when a request is sent. Sending creates a new pending request, so make outgoing changes on the request or in its hooks and middleware rather than on an inspected pending request.
For a one-off request that does not need an integration connector, extend SoloRequest and return an absolute HTTP or HTTPS endpoint:
use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\SoloRequest; class GetStatus extends SoloRequest { protected Method $method = Method::GET; public function resolveEndpoint(): string { return 'https://status.example.com/api/status'; } } $response = (new GetStatus)->send();
Standalone requests define their headers, query parameters, options, authentication, and body using the same default methods as other requests.
Warning
A standalone request sends its headers and credentials to whatever URL resolveEndpoint returns. Do not build the endpoint from user input.
A request that always uses the same connector may send itself with the HasConnector trait. Define the connector class in a $connector property:
use App\Http\Integrations\GitHub\GitHubConnector; use Hypervel\Saloon\Traits\Request\HasConnector; class GetUser extends Request { use HasConnector; protected string $connector = GitHubConnector::class; // ... } $response = GetUser::make('hypervel')->send();
The request creates its connector once, and the connector method returns it. Use setConnector to supply a configured instance instead, or override resolveConnector when the connector needs constructor arguments. The createPendingRequest method prepares the request through that connector.
Normal connector requests accept relative endpoints. An endpoint without a scheme is always a path on the connector's host, even when it contains a colon, such as documents:batchGet, or begins with //. An absolute endpoint is rejected unless the request or connector explicitly opts into base URL replacement. This protects credentials from being sent to an unexpected host.
Override allowsBaseUrlOverride on a request to return true when that request may use an application-controlled absolute endpoint. The request method returns null by default, which inherits the connector's decision:
public function allowsBaseUrlOverride(): ?bool { return true; }
To allow absolute endpoints for every request sent by a connector, override the connector's allowsBaseUrlOverride method instead. The connector method returns bool and defaults to false.
Only enable this behavior for endpoints your application trusts.
Request classes may define the same protected defaults as connectors. Request values take precedence when both classes provide the same setting:
use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Http\Auth\TokenAuthenticator; protected function defaultHeaders(): array { return ['X-Api-Version' => '2026-01-01']; } protected function defaultQuery(): array { return ['include' => 'profile']; } protected function defaultOptions(): array { return ['allow_redirects' => false]; } protected function defaultAuth(): ?Authenticator { return new TokenAuthenticator($this->token); } protected function defaultDelay(): ?int { return 100; }
Body traits add a defaultBody method for their body type. Use boot(PendingRequest $pendingRequest) when a default depends on the complete operation rather than the request constructor alone.
Requests and pending requests provide fluent methods that mirror Hypervel's HTTP client. You may apply these methods before sending a request or from connector, request, middleware, and plugin hooks.
Use withHeader to add one header or withHeaders to merge several headers. Adding a header that is already present keeps both values. The replaceHeaders method replaces matching header names without removing unrelated headers. Header names are matched without regard to casing:
$request ->withHeader('X-Trace-ID', $traceId) ->withHeaders([ 'X-Account' => $accountId, 'X-Feature' => 'reports', ]) ->acceptJson() ->withUserAgent('Acme Application/1.0');
The withoutHeader and withoutHeaders methods remove headers, also ignoring case. Removing a header from a request only affects the request's own headers, since connector defaults are merged into the pending request when the request is sent. To remove a connector default, remove it from the pending request in a hook or middleware:
use Hypervel\Saloon\Http\PendingRequest; public function boot(PendingRequest $pendingRequest): void { $pendingRequest->withoutHeader('X-Api-Version'); }
Use withUrl on a request when an API supplies a complete URL:
$request->withUrl('https://api.example.com/repositories/123/issues?page=2');
The URL must be absolute HTTP or HTTPS. It replaces both the connector base URL and the request endpoint, without requiring allowsBaseUrlOverride. Authentication and middleware use the new URL. Only supply trusted URLs, since the request retains its credentials.
The url method returns the override, or null when none was supplied. Explicit query strings and query parameters are still applied as described below.
The withUrlParameters method fills URI template placeholders in the connector base URL, the request endpoint, or the URL override, just like Hypervel's HTTP client:
// Base URL: https://{region}.api.example.com // Endpoint: /accounts/{account} $request->withUrlParameters([ 'region' => 'eu', 'account' => $accountId, ]);
Values in simple placeholders such as {account} are percent-encoded, so slashes and query characters in a value stay part of that value. Placeholders are only replaced when parameters are given.
Pending requests also provide withUrl and withMethod, so a hook or middleware may redirect a single operation without changing the request instance:
use Hypervel\Saloon\Enums\Method; $pendingRequest ->withUrl('https://uploads.example.com/files') ->withMethod(Method::PUT);
Query parameters may be supplied using withQueryParameters:
$request->withQueryParameters([ 'page' => 2, 'per_page' => 50, ]);
Request values replace connector values with the same key. Values added later by middleware replace earlier values. Query parameters already present in the connector base URL or request endpoint are preserved unless the request contains the same top-level key.
Query parameters are encoded using PHP's http_build_query function, so arrays use bracketed names such as filter[active]=1, booleans become 1 or 0, and null values are left out. When an API expects another format, use the withQueryString method described below.
The withoutQueryParameters method removes parameters added through withQueryParameters or defaultQuery. As with headers, removing a parameter from a request does not remove a connector default; remove it from the pending request instead. Values embedded in the base URL, endpoint, or withQueryString query are unchanged:
$request->withoutQueryParameters(['page', 'per_page']);
Use withQueryString when an API supplies an already-encoded query, including repeated parameter names:
$request->withQueryString('tag=php&tag=hypervel&cursor=a%2Fb');
The query should not include a leading ?. It replaces the query in the base URL and request endpoint. Passing an empty string clears that query. Parameters defined on the connector or added using withQueryParameters, including authentication parameters, are still applied and take precedence when names match.
You may define a default query string by overriding defaultQueryString(): ?string on your request. Returning null leaves the URL's query unchanged. The queryString method returns this string, while queryParameters returns the separately configured array parameters. Middleware may also call withQueryString on a pending request to replace the query for that attempt.
The HasApiVersion plugin sends an API version with each operation. Assign the version in the connector or request constructor, and choose where it is sent with the $versionMode property: a header (the default), a query parameter, or the URL. The $versionKey property names the header or query parameter and defaults to api-version:
use Hypervel\Saloon\Enums\VersionMode; use Hypervel\Saloon\Traits\Plugins\HasApiVersion; class AnthropicConnector extends Connector { use HasApiVersion; public function __construct() { $this->apiVersion = '2023-06-01'; $this->versionKey = 'anthropic-version'; } // ... }
In URL mode, the version replaces a {version} placeholder in the base URL, endpoint, or URL override, such as https://generativelanguage.googleapis.com/{version}. The version may only contain letters, numbers, dashes, underscores, and single dots, so it can only fill that subdomain label or path segment. You may override getApiVersion to calculate the version. When both a connector and a request send a version, the request's version is used.
Saloon provides the same familiar authentication methods as Hypervel's HTTP client:
$request->withToken($token); $request->withBasicAuth($username, $password); $request->withDigestAuth($username, $password);
These methods create authenticators, which you may also use directly. Return an authenticator from a connector or request's defaultAuth method, or pass one to authenticate. Saloon includes header, query, cookie, token, basic, digest, certificate, access-token, and multi-authenticator implementations under Hypervel\Saloon\Http\Auth:
use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Http\Auth\HeaderAuthenticator; protected function defaultAuth(): ?Authenticator { return new HeaderAuthenticator($this->key, 'X-Api-Key'); }
A request's authenticator replaces its connector's. When an API requires more than one form of authentication, such as a client certificate and a token, combine them using MultiAuthenticator:
use Hypervel\Saloon\Http\Auth\CertificateAuthenticator; use Hypervel\Saloon\Http\Auth\MultiAuthenticator; use Hypervel\Saloon\Http\Auth\QueryAuthenticator; $request->authenticate(new MultiAuthenticator( new CertificateAuthenticator('/path/to/client.pem', $password), new QueryAuthenticator('api_key', $key), ));
Headers set by an authenticator are removed when a redirect changes the scheme, host, or port. They remain removed for the rest of that redirect chain, even if it returns to the original origin. A new retry attempt applies authentication again. Custom headers added through defaultHeaders or withHeaders outside an authenticator are ordinary headers and may be forwarded to another origin.
Built-in NTLM authentication is not provided. Integrations requiring NTLM must supply their own authenticator and transport middleware.
You may create a reusable authenticator by implementing Hypervel\Saloon\Contracts\Authenticator:
use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Http\PendingRequest; class ApiKeyAuthenticator implements Authenticator { public function __construct( private readonly string $key, private readonly string $accountId, ) { } public function set(PendingRequest $pendingRequest): void { $pendingRequest->withHeaders([ 'X-Api-Key' => $this->key, 'X-Account-Id' => $this->accountId, ]); } }
Use a custom authenticator like the built-in ones:
$request->authenticate(new ApiKeyAuthenticator($key, $accountId));
For APIs that authenticate using a cookie, use CookieAuthenticator:
use Hypervel\Saloon\Http\Auth\CookieAuthenticator; $request->authenticate(new CookieAuthenticator('session', $token));
By default, the cookie is sent only to the request's host, not its subdomains. For HTTPS requests, it is also marked Secure so it cannot be sent over HTTP. You may pass a domain such as .example.com as the third argument to include subdomains; an empty domain is not allowed. If you replace the authenticator using the same cookie name and domain argument, the new value is used when sending the request.
Add the RequiresAuth trait to a request that must never be sent without an authenticator:
use Hypervel\Saloon\Traits\Auth\RequiresAuth; class GetPrivateReport extends Request { use RequiresAuth; // ... }
You may also add the trait to a connector to require authentication for every request in that integration. Saloon throws MissingAuthenticatorException before sending an unauthenticated request. You may override getRequiresAuthMessage(PendingRequest $pendingRequest): string when the integration needs a more specific message.
For APIs that issue reusable access tokens, use cached authentication instead of requesting a token for every API call. Here, $oauth is a client-credentials connector:
$request->authenticate($oauth->cachedAccessToken(store: 'redis'));
For user-authorized connections with refresh tokens, use connected OAuth accounts. Token endpoint requests must not use the authenticator that fetches their own token; use a separate OAuth connector when your API connector supplies authentication by default.
The HasJsonBody, HasFormBody, HasMultipartBody, HasStringBody, HasStreamBody, and HasXmlBody traits declare the default body type for a request. For example, a JSON request may define its default values using the defaultBody method:
use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Request; use Hypervel\Saloon\Traits\Body\HasJsonBody; class CreateIssue extends Request { use HasJsonBody; protected Method $method = Method::POST; public function __construct( private readonly string $title, ) { } public function resolveEndpoint(): string { return '/issues'; } protected function defaultBody(): array { return ['title' => $this->title]; } }
The JSON, form, and XML traits supply the appropriate content type unless you already defined one. A multipart body always receives multipart/form-data with its boundary unless the content type you define declares a boundary. String and stream bodies do not assume a content type, so specify one using contentType or defaultHeaders. Body traits may also be used on connectors when every request shares the same body format. A request's JSON and form values are merged into the connector's, replacing values with the same key, and its multipart parts are added after the connector's. A request's string, XML, or stream body replaces the connector's.
When both a connector and request define a body, their body repositories must be the same type. For example, a request using HasFormBody cannot be sent through a connector using HasJsonBody. Saloon throws a PendingRequestException when the body types do not match.
You may select or replace the body fluently:
$request->asJson(['name' => 'Taylor']); $request->asForm(['name' => 'Taylor']); $request->withBody($stream, 'application/octet-stream'); $request->withBody($stream, null);
The withBody method accepts a string, stream, or resource. Like the HTTP client's method of the same name, it sets an application/json content type unless you pass another one, or null to leave the content type unchanged.
The withData method merges structured values into the current JSON or form body. If the request has no JSON, form, or multipart body, the method creates a JSON body:
$request->withData(['active' => true]);
On a multipart body, withData adds each value as a field after the existing parts. Fields are appended, so repeating a name adds another field instead of replacing the first. Nested arrays are sent as bracketed field names such as roles[0], true is sent as 1, and false and null are sent as empty fields.
Arrayable and JsonSerializable objects, and the fluent strings returned by Str::of, are converted recursively in JSON, form, and query data.
JSON bodies use JSON_THROW_ON_ERROR by default. To use other encoding flags, return a configured JsonBodyRepository from defaultBodyRepository:
use Hypervel\Saloon\Contracts\Body\BodyRepository; use Hypervel\Saloon\Repositories\Body\JsonBodyRepository; protected function defaultBodyRepository(): ?BodyRepository { return (new JsonBodyRepository($this->defaultBody())) ->setJsonFlags(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); }
The request may still use HasJsonBody so Saloon supplies the JSON content type. When the connector also defines a JSON body, the request's values are merged into the connector's repository, so the connector's flags apply. Invalid JSON values throw a BodyException instead of being sent as an empty body.
Use the attach method to add multipart values or files. The optional filename and headers are applied to that part:
$request ->asMultipart() ->attach('document', fopen($path, 'rb'), 'report.pdf', [ 'Content-Type' => 'application/pdf', ]) ->attach('description', 'Quarterly report');
You may also pass an array of parts, each containing the same arguments:
$request->attach([ ['document', fopen($path, 'rb'), 'report.pdf', ['Content-Type' => 'application/pdf']], ['description', 'Quarterly report'], ]);
When a request has a body in another format, attach replaces it with a multipart body. The asMultipart method selects a multipart body without adding a value. Neither method discards values that a multipart body already contains.
Saloon preserves multipart streams, filenames, headers, order, and the generated boundary. Caller-owned streams are not buffered automatically.
A request using the HasMultipartBody trait returns its default parts from defaultBody as MultipartValue instances, each containing the part's name, contents, and an optional filename and headers. String contents are sent as they are, so open files using fopen:
use Hypervel\Saloon\Data\MultipartValue; use Hypervel\Saloon\Traits\Body\HasMultipartBody; class UploadAvatar extends Request { use HasMultipartBody; // ... protected function defaultBody(): array { return [ new MultipartValue('avatar', fopen($this->path, 'rb'), 'avatar.png', [ 'Content-Type' => 'image/png', ]), new MultipartValue('description', 'Profile photo'), ]; } }
If an API requires another body format, you may implement Hypervel\Saloon\Contracts\Body\BodyRepository. A repository stores the logical value and converts it into a PSR-7 stream when the operation is finalized:
use GuzzleHttp\Psr7\Utils; use Hypervel\Saloon\Contracts\Body\MergeableBody; use InvalidArgumentException; use Psr\Http\Message\StreamInterface; class NdjsonBodyRepository implements MergeableBody { public function __construct( private array $records = [], ) { } public function set(mixed $value): static { if (! is_array($value)) { throw new InvalidArgumentException('The NDJSON body must be an array of records.'); } $this->records = $value; return $this; } public function all(): array { return $this->records; } public function merge(array ...$arrays): static { $this->records = array_merge($this->records, ...$arrays); return $this; } public function isEmpty(): bool { return $this->records === []; } public function isNotEmpty(): bool { return ! $this->isEmpty(); } public function toStream(): StreamInterface { $body = implode("\n", array_map( fn (mixed $record) => json_encode($record, JSON_THROW_ON_ERROR), $this->records, )); return Utils::streamFor($body); } }
Implement MergeableBody when connector values should be merged with request values. A repository that implements only BodyRepository is replaced as a complete value instead. The withData method is reserved for the built-in JSON, form, and multipart repositories; calling it on a custom repository selects a JSON body.
Return the repository from the request or connector's defaultBodyRepository method and set the appropriate content type during boot:
use Hypervel\Saloon\Contracts\Body\BodyRepository; use Hypervel\Saloon\Http\PendingRequest; protected function defaultBodyRepository(): ?BodyRepository { return new NdjsonBodyRepository($this->records); } public function boot(PendingRequest $pendingRequest): void { $pendingRequest->contentType('application/x-ndjson'); }
You may configure request timeouts, redirects, TLS verification, cookies, response sinks, delays, retries, and other supported Guzzle options:
$request ->timeout(30) ->connectTimeout(5) ->maxRedirects(3) ->withoutVerifying() ->withCookies(['locale' => 'en'], '.example.com') ->delay(100) ->withOptions(['proxy' => $proxy]);
The timeout and connectTimeout methods accept seconds, while delay accepts milliseconds. Saloon waits for the delay before every attempt, including attempts answered by a mock or fake response, so Sleep::fake() can assert it in your tests. Responses served from the cache are not delayed.
The withoutRedirecting method disables redirects, and the sink method writes the response body to a path or resource as it is received.
The withoutOptions method removes options from the request, so the connector's value or the HTTP connection's configured value applies again. To remove a connector default, call withoutOptions on the pending request:
$request->withoutOptions(['timeout', 'connect_timeout']);
The HasTimeout plugin gives a connector or request default timeouts from its $connectTimeout and $requestTimeout properties, in seconds. You may override the getConnectTimeout or getRequestTimeout methods to calculate them instead. Options set on the same connector or request take precedence over its declared timeouts, and a request's timeouts take precedence over its connector's. A timeout that is not declared leaves the existing options and the HTTP connection's configured value in effect:
use Hypervel\Saloon\Traits\Plugins\HasTimeout; class GitHubConnector extends Connector { use HasTimeout; protected int $connectTimeout = 5; protected int $requestTimeout = 60; public function resolveBaseUrl(): string { return 'https://api.github.com'; } }
To specify a cookie's path or other attributes, use withCookie with a Guzzle SetCookie instance. The cookie must include a domain:
use GuzzleHttp\Cookie\SetCookie; $request->withCookie(new SetCookie([ 'Name' => 'locale', 'Value' => 'en', 'Domain' => 'api.example.com', 'Path' => '/reports', 'Secure' => true, ]));
Request-shaping options such as headers, query, cookies, body, json, form_params, multipart, auth, delay, and http_errors must be configured through Saloon's dedicated methods. Transport sharing belongs to a fixed Hypervel HTTP connection, while request handlers, object pools, and connection caps are not accepted through withOptions.
Middleware may inspect or change the pending request, response, or fatal transport exception. You may register middleware on a request before sending it, from a connector or request's boot method, through a reusable plugin, or globally during application boot.
Register request middleware using the pending request's middleware pipeline:
use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Support\Str; public function boot(PendingRequest $pendingRequest): void { $pendingRequest->middleware()->onRequest( function (PendingRequest $pendingRequest): void { $pendingRequest->withHeader('X-Request-ID', (string) Str::uuid()); }, name: 'request-id', ); }
Requests have the same middleware pipeline, so you may also add middleware to a request before sending it:
$request = GetUser::make('hypervel'); $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use ($traceId): void { $pendingRequest->withHeader('X-Trace-ID', $traceId); });
Request middleware may return a MockResponse or another implementation of FakeResponse to short-circuit the network request. The remaining request middleware still runs, and the last fake response returned is used. A fake response supplied by middleware bypasses the response cache and mock clients, and consumes no rate limit capacity. Other return values are ignored, so change the pending request it receives rather than returning a new one.
For reusable middleware, create an invokable class that implements RequestMiddleware:
use Hypervel\Saloon\Contracts\FakeResponse; use Hypervel\Saloon\Contracts\RequestMiddleware; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Support\Str; class AddRequestId implements RequestMiddleware { public function __invoke(PendingRequest $pendingRequest): ?FakeResponse { $pendingRequest->withHeader('X-Request-ID', (string) Str::uuid()); return null; } }
Register the class in the same way as a closure: $pendingRequest->middleware()->onRequest(new AddRequestId).
Middleware runs in registration order. Saloon registers global middleware first, followed by plugin middleware, middleware added by the connector's boot method, the request's own middleware, and middleware added by the request's boot method. You may pass PipeOrder::FIRST or PipeOrder::LAST using the order argument when a middleware must run before or after the normal group.
These sources are merged into the operation's pipelines, so a name may be used only once among its request middleware, and likewise among its response and fatal exception middleware. A duplicate name throws a DuplicatePipeNameException. Request middleware may register response middleware, but request middleware added while the request pipeline is running does not run for that operation.
Response middleware runs after a response is received or supplied by a Saloon fake or cache hit:
use Hypervel\Saloon\Http\Response; $pendingRequest->middleware()->onResponse( function (Response $response): void { logger()->info('API request completed', [ 'status' => $response->status(), ]); }, );
A response middleware callback may return another Saloon response to replace the current response.
Invokable response middleware classes may implement ResponseMiddleware. Their __invoke method returns a replacement response or null:
use Hypervel\Saloon\Contracts\ResponseMiddleware; use Hypervel\Saloon\Http\Response; class RecordResponse implements ResponseMiddleware { public function __invoke(Response $response): ?Response { logger()->info('API request completed', ['status' => $response->status()]); return null; } }
Fatal exception middleware receives connection and transport failures after they have been wrapped in a FatalRequestException, as well as a FatalRequestException thrown by a fake response to simulate one:
use Hypervel\Saloon\Exceptions\Request\FatalRequestException; $pendingRequest->middleware()->onFatalException( function (FatalRequestException $exception): void { report($exception); }, );
You may register middleware for every Saloon request through the facade. Global middleware persists for the worker lifetime and should be registered only during application boot:
use Hypervel\Saloon\Facades\Saloon; use Hypervel\Saloon\Http\PendingRequest; public function boot(): void { $applicationName = config()->string('app.name'); Saloon::middleware()->onRequest( function (PendingRequest $request) use ($applicationName): void { $request->withHeader('X-Application', $applicationName); }, ); }
For signing schemes or libraries that require the final PSR-7 request, override handlePsrRequest on a connector or request:
use Hypervel\Saloon\Http\PendingRequest; use Psr\Http\Message\RequestInterface; public function handlePsrRequest( RequestInterface $request, PendingRequest $pendingRequest, ): RequestInterface { return $request->withHeader('X-Signature', $this->sign($request)); }
The connector hook runs before the request hook. Each hook runs once on the final PSR-7 request for network, fake, and cache-hit responses.
Warning
Saloon's default cache key is created before lower-level Hypervel HTTP middleware or a custom PSR-7 hook can change the response identity. If either layer changes the URI, headers, body, authentication, or another response-affecting value, define a matching custom cache key.
Plugins are traits with a public boot method named after the trait. They allow connector and request behavior to be shared without another service layer:
use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Support\Str; trait AddsRequestId { public function bootAddsRequestId(PendingRequest $pendingRequest): void { $pendingRequest->withHeader('X-Request-ID', (string) Str::uuid()); } }
Use the trait on any connector or request. Saloon discovers plugin boot methods once per concrete class and invokes them for each operation. Change the pending request in a plugin rather than the connector or request, since a connector may be shared by concurrent operations. A plugin used by both a connector and its request boots twice.
Connector plugins boot before request plugins, and the request is authenticated once every plugin has booted. This lets an authenticator use the URL and headers set by plugins, such as an API version in the host. A plugin may select the authenticator by calling authenticate on the pending request; Saloon applies it after the remaining plugins boot. The connector and request boot methods and request middleware run after authentication, so calling authenticate there applies the authenticator immediately.
Saloon responses inherit the complete Hypervel HTTP response API. For example, you may inspect JSON data, headers, status codes, transfer information, and the underlying PSR response:
$response->body(); $response->json('user.name'); $response->object(); $response->collect('items'); $response->status(); $response->successful(); $response->clientError(); $response->serverError(); $response->header('X-RateLimit-Remaining'); $response->toPsrResponse(); $response->dataUrl();
The dataUrl method returns the response body as a base64 data URL using its Content-Type header. The isJson and isXml methods check whether that header names a JSON or XML type, and array is an alias of json.
You may use lines and jsonLines to process a response as it arrives. Enable the stream request option and leave response caching and fixture recording disabled, since both read the body before returning the response.
These methods continue from the body's current position. If you have already called body, call $response->stream()->rewind() before reading its lines.
Saloon also provides access to the integration objects and final request:
$response->connector(); $response->request(); $response->pendingRequest(); $response->toPsrRequest(); $response->isCached(); $response->isMocked(); $response->isFaked();
The xml method converts the response body into a SimpleXMLElement, while dom returns a Symfony DOM crawler:
$xml = $response->xml(); $name = $xml === false ? null : (string) $xml->user->name; $title = $response->dom()->filter('title')->text();
Use saveBodyToFile to copy the response body to a path or writable resource:
$response->saveBodyToFile(storage_path('app/report.pdf')); $stream = fopen('php://temp', 'wb+'); $response->saveBodyToFile($stream, closeResource: false);
The getRawStream method returns a temporary PHP resource containing the response body. The stream method returns the underlying PSR-7 stream. Saloon preserves the position of seekable response streams. A non-seekable response stream is copied from its current position.
A request or connector may convert responses into any value using createDtoFromResponse. Your data object may be a plain class, a Hypervel data object, or an object from another DTO package:
use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Http\Request; use Hypervel\Data\Data; class GitHubUserData extends Data { public function __construct( public readonly int $id, public readonly string $login, ) { } } /** @extends Request<GitHubUserData> */ class GetUser extends Request { // ... public function createDtoFromResponse(Response $response): GitHubUserData { return GitHubUserData::from($response->json()); } }
Call dto to create the configured value. The dto method converts every response, including failed ones, so you may also map error responses. The dtoOrFail method instead throws a LogicException when the integration considers the response failed. When the response would also throw, its request exception is available as the LogicException's previous exception:
$user = $github->send(new GetUser('hypervel'))->dtoOrFail();
The generic @extends Request<GitHubUserData> annotation allows static analysis to infer the return type through send, dto, and dtoOrFail. No runtime DTO interface is required. A data object may implement Hypervel\Saloon\Contracts\DataObjects\WithResponse when it needs access to the response that created it.
Saloon's HasResponse trait implements this contract for you:
use Hypervel\Saloon\Contracts\DataObjects\WithResponse; use Hypervel\Saloon\Traits\Responses\HasResponse; use Hypervel\Data\Data; class GitHubUserData extends Data implements WithResponse { use HasResponse; // ... } $response = $user->getResponse();
To add integration-specific response methods, extend Hypervel\Saloon\Http\Response and set the request or connector's $response property:
use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Http\Request; class GitHubResponse extends Response { public function requestId(): ?string { return $this->header('X-GitHub-Request-Id') ?: null; } } class GetUser extends Request { protected ?string $response = GitHubResponse::class; // ... }
If the response class depends on response data, override resolveResponseClass(Response $response) and return a Saloon response class or null. A request resolver takes precedence over a connector resolver.
Saloon does not throw automatically for HTTP error responses. You may inspect the response or call throw:
if ($response->failed()) { // ... } $response->throw();
Common statuses throw an exception named after them from the Hypervel\Saloon\Exceptions\Request\Statuses namespace:
| Status | Exception |
|---|---|
| 400 | BadRequestException |
| 401 | UnauthorizedException |
| 402 | PaymentRequiredException |
| 403 | ForbiddenException |
| 404 | NotFoundException |
| 405 | MethodNotAllowedException |
| 408 | RequestTimeOutException |
| 409 | ConflictException |
| 422 | UnprocessableEntityException |
| 429 | TooManyRequestsException |
| 500 | InternalServerErrorException |
| 502 | BadGatewayException |
| 503 | ServiceUnavailableException |
| 504 | GatewayTimeoutException |
These extend ClientException for 4xx responses and ServerException for 5xx responses, which other 4xx and 5xx responses throw directly. All of them extend RequestException, which other integration-defined failures throw.
A request that could not complete throws FatalRequestException. This includes failed connections and transfers that break while the response is being received, whatever status the partial response has.
Some APIs report errors in a successful response. A request or connector may override hasRequestFailed to decide whether a response failed, returning null to fall back to the status code:
use Hypervel\Saloon\Http\Response; public function hasRequestFailed(Response $response): ?bool { return $response->json('ok') === false ? true : null; }
In this example, a response whose body contains "ok": false is treated as failed whatever its status. A request's non-null decision takes precedence over its connector's. Failed responses are not cached, and by default they throw from throw and trigger retries.
A response throws when the shouldThrowRequestException method of either its request or its connector returns true. Both return failed() by default. To stop a failed response from throwing while keeping it failed, return false from both methods. Returning false from hasRequestFailed instead makes the response successful, so it may also be cached and converted by dtoOrFail.
You may return a custom Saloon request exception from getRequestException, and a request's exception takes precedence over its connector's. To give a custom exception its own message, override its prepareMessage method:
use Hypervel\Http\Client\Response; use Hypervel\Saloon\Exceptions\Request\RequestException; class GitHubException extends RequestException { protected function prepareMessage(Response $response): string { return 'GitHub: ' . $response->json('message'); } }
Then return it from the request or connector:
use Hypervel\Saloon\Exceptions\Request\RequestException; use Hypervel\Saloon\Http\Response; public function getRequestException(Response $response): ?RequestException { return new GitHubException($response); }
The AlwaysThrowOnErrors plugin calls throw automatically after each response.
The retry method accepts the maximum number of times a request should be attempted and the number of milliseconds to wait between attempts. A request is retried when its response would throw or the request could not complete:
$request->retry(3, 100);
You may calculate the delay using a closure, or pass an array of delays as the first argument, which makes one more attempt than it contains delays:
$request->retry(3, fn (int $attempt) => $attempt * 100); $request->retry([100, 250, 500]);
When a request with more than one attempt runs out of attempts, Saloon throws the last attempt's RequestException or FatalRequestException. A request with one attempt, such as retry(1), returns a failed response as usual. Pass throw: false to return the last response instead of throwing. A FatalRequestException is still thrown, because there is no response to return, as is a request exception thrown by response middleware, such as the AlwaysThrowOnErrors plugin's:
$request->retry(3, 100, throw: false);
Each attempt receives a fresh pending request, so middleware and authentication run again. Seekable request bodies are restored before another attempt. Saloon throws a BodyException rather than retrying a consumed non-seekable body.
The when callback receives the exception and the failed attempt's pending request. Changes made to the original request through $pendingRequest->request() apply to the next attempt, such as replacing an expired token:
use Hypervel\Saloon\Exceptions\Request\RequestException; use Hypervel\Saloon\Http\PendingRequest; $request->retry(2, when: function ($exception, PendingRequest $pendingRequest) { if (! $exception instanceof RequestException || $exception->status() !== 401) { return false; } $pendingRequest->request()->withToken($this->refreshToken()); return true; });
To retry every request sent through a connector, return a policy from the connector's defaultRetryPolicy method. A request may define the same method to set its own default:
use Hypervel\Saloon\Data\RetryPolicy; protected function defaultRetryPolicy(): ?RetryPolicy { return new RetryPolicy(3, 100); }
Saloon uses the policy passed to the request's retry method, then the request's default, then the connector's default. A request's policy replaces the connector's entirely, including its when condition, so $request->retry(1) turns off the connector's retries. Middleware and boot methods may also call retry on the pending request to change the policy for a single operation.
Use debugRequest, debugResponse, or debug on a request to inspect one operation:
$request->debug(); $request->debugRequest(); $request->debugResponse();
To debug every request sent through a connector, call the same methods on the pending request from the connector's boot method. All three methods accept die: true, which stops the current request or command after the output and is intended only for local debugging.
Saloon dumps the request and response using Symfony's VarDumper. The request is shown as Saloon passes it to the HTTP client, after PSR-7 request hooks have run, so headers that HTTP client middleware or Guzzle add later are not included. The response is shown before response middleware that uses the default order.
The debugRequest and debugResponse methods also accept a callback, such as one that writes to your logs instead:
use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Saloon\Http\Response; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; $request->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest): void { logger()->debug('Saloon request', ['uri' => (string) $psrRequest->getUri()]); }); $request->debugResponse(function (Response $response, ResponseInterface $psrResponse): void { logger()->debug('Saloon response', ['status' => $response->status()]); });
Warning
Debug output contains raw headers and bodies and may expose credentials or other sensitive values. Custom callbacks are also responsible for any stream reads they perform.
Saloon includes support for the OAuth 2 authorization code, client credentials, and client credentials with HTTP Basic authentication grants. OAuth configuration is immutable so a connector may be reused safely by concurrent requests.
To use the authorization code grant, add the AuthorizationCodeGrant trait to your connector and define its OAuth configuration:
use Hypervel\Saloon\Data\OAuthConfig; use Hypervel\Saloon\Traits\OAuth2\AuthorizationCodeGrant; class GitHubConnector extends Connector { use AuthorizationCodeGrant; // ... protected function defaultOAuthConfig(): OAuthConfig { return new OAuthConfig( clientId: config()->string('services.github.client_id'), clientSecret: config()->string('services.github.client_secret'), redirectUri: route('github.callback'), authorizeEndpoint: 'https://github.com/login/oauth/authorize', tokenEndpoint: 'https://github.com/login/oauth/access_token', userEndpoint: '/user', defaultScopes: ['read:user'], allowBaseUrlOverride: true, ); } }
The authorize, token, and user endpoints default to authorize, token, and user, relative to the connector's base URL. The allowBaseUrlOverride option is required here because GitHub's trusted authorization and token endpoints use a different host from the connector's API base URL. Leave this option disabled when the OAuth endpoints are relative to the connector base URL.
If each tenant has its own OAuth client, pass the client credentials to the connector's constructor and use them in defaultOAuthConfig.
The authorizationUrl method returns the URL together with its generated state. Store the state for the current authorization attempt before redirecting the user:
$authorization = $github->authorizationUrl(['user:email']); session(['github_oauth_state' => $authorization->state]); return redirect((string) $authorization);
The given scopes are added to the configuration's defaultScopes. The state is a random 32-character string unless you pass your own state argument. The authorizationUrl method also accepts a scopeSeparator and an array of additionalQueryParameters. Additional parameters may replace the standard ones, such as response_type for an OpenID Connect hybrid flow, but the URL's state always matches the returned state.
After the provider redirects to your application, exchange the code and validate the returned state:
$authenticator = $github->getAccessToken( code: $request->string('code')->toString(), state: $request->string('state')->toString(), expectedState: session('github_oauth_state'), ); $response = $github->send( (new GetUser('hypervel'))->authenticate($authenticator), );
If either state value is supplied, both values must be non-empty and equal, or Saloon throws an InvalidStateException. If the provider rejects the code, getAccessToken throws the token response's request exception. Keeping the state beside each authorization attempt allows one connector to support concurrent tabs and users without shared mutable state.
You may pass a PKCE code challenge to authorizationUrl and the corresponding verifier to getAccessToken:
$authorization = $github->authorizationUrl( codeChallenge: $challenge, codeChallengeMethod: 'S256', ); $authenticator = $github->getAccessToken( code: $code, state: $returnedState, expectedState: $expectedState, codeVerifier: $verifier, );
The refreshAccessToken method accepts either a refresh-token string or an OAuth authenticator containing a refresh token. The getUser method sends the configured user request with an OAuth authenticator.
For machine-to-machine integrations, use the ClientCredentialsGrant trait:
use Hypervel\Saloon\Traits\OAuth2\ClientCredentialsGrant; class ServiceConnector extends Connector { use ClientCredentialsGrant; // Define resolveBaseUrl and defaultOAuthConfig... } $authenticator = $connector->getAccessToken(['reports:read']);
The given scopes are added to the configuration's defaultScopes and separated by spaces unless you pass a scopeSeparator. Client credentials connectors need no redirectUri. Use ClientCredentialsBasicAuthGrant when the provider requires the client credentials in an HTTP Basic authentication header.
ClientCredentialsBasicAuthGrant form-encodes the client ID and secret before adding them to the header, as the OAuth 2 specification requires. If your provider expects unencoded credentials, you may customize the token request.
OAuth token grant methods return an OAuth authenticator by default. Pass returnResponse: true when you need the original Saloon response instead:
$response = $connector->getAccessToken( scopes: ['reports:read'], returnResponse: true, );
Client credentials connectors provide a cachedAccessToken method. It returns an authenticator that fetches a token on first use and reuses it until renewal is due:
$authenticator = $oauth->cachedAccessToken( scopes: ['reports:read'], store: 'redis', ); $response = $api->send((new GetReports)->authenticate($authenticator));
The cache key includes the connector class, token URL, client credentials and requested scopes. Credentials are hashed, never included directly in cache keys. Fresh connector and authenticator instances with the same grant reuse the shared entry. Account and merchant headers stay on each request or connector.
If a request modifier or custom request factory changes the grant, include that difference in the optional key argument:
$authenticator = $oauth->cachedAccessToken( store: 'redis', requestModifier: fn ($request) => $request->withData(['audience' => 'billing']), key: 'billing', );
The store defaults to saloon.cache.store, then the framework's default cache store. Use Redis to share tokens across workers. The worker-array store shares within one worker; the ordinary array store is local to a coroutine. Concurrent callers in one worker share the active fetch and its result or failure. Different workers may safely acquire their own client-credentials tokens on a simultaneous cache miss.
Tokens renew up to 30 seconds before expiry, capped at half their remaining lifetime when acquired. This deadline does not move when an entry is read. Tokens without expiry do not renew automatically; supply an expiry when the provider documents one. An already-expired result is rejected.
For APIs with a username/password token endpoint, supply a resolver returning OAuthAuthenticator. In this example, $auth is a connector for the authentication API, and LoginRequest exchanges the credentials for a token and its lifetime:
use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; use Hypervel\Saloon\Http\Auth\CachedTokenAuthenticator; use Hypervel\Support\Facades\Date; $authenticator = new CachedTokenAuthenticator( key: serialize([LoginRequest::class, $auth->resolveBaseUrl(), $username, $password]), resolver: function () use ($auth, $username, $password): AccessTokenAuthenticator { $response = $auth->send(new LoginRequest($username, $password))->throw(); return new AccessTokenAuthenticator( accessToken: $response->json('token'), expiresAt: Date::now()->addSeconds($response->json('expires_in'))->toDateTimeImmutable(), ); }, store: 'redis', );
Your grant identity must distinguish credentials, environments and any audience or other grant inputs. Saloon hashes it before storage. The cache retains the actual authenticator, including custom authentication behavior. Register custom classes through the cache's allowSerializableClassesUsing method during provider boot, and use a serializer that preserves PHP objects. Cached standalone tokens contain usable credentials, so protect access to the cache.
Call $authenticator->forget() to clear that instance's token and its shared entry. This does not revoke the provider token or clear copies held by other live instances. Use connected accounts for rotating refresh tokens, which require durable storage and coordinated updates.
An AccessTokenAuthenticator exposes its access token, optional refresh token, expiry, reported scopes, and expiration helpers:
if ($authenticator->hasExpired() && $authenticator->isRefreshable()) { $authenticator = $connector->refreshAccessToken($authenticator); }
For automatic persistence and renewal, use connected OAuth accounts. You may also manage tokens yourself using encrypted model attributes and an immutable_datetime cast for expiry:
use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; $authenticator = new AccessTokenAuthenticator( $user->github_access_token, $user->github_refresh_token, $user->github_token_expires_at, );
OAuth token expiry values use immutable dates. Saloon rejects negative or unrepresentable expiry durations instead of creating an already-expired token.
getScopes() returns the scopes reported by the provider, or null when none were reported. Saloon does not infer granted permissions from the requested scopes. A custom authenticator may supply [] to represent an explicitly empty set.
Refresh requests use the configured tokenEndpoint by default. If your provider uses a separate endpoint, set refreshEndpoint on your OAuthConfig:
return new OAuthConfig( clientId: $this->clientId, clientSecret: $this->clientSecret, redirectUri: $this->redirectUri, tokenEndpoint: 'oauth/token', refreshEndpoint: 'oauth/refresh', );
For an absolute refresh URL, also set allowBaseUrlOverride: true.
Connected accounts store user-authorized credentials and renew them when needed. Generate and run the migration only if your application uses this feature:
php artisan make:oauth-accounts-table php artisan migrate
The oauth-accounts:table alias is also available. The migration creates oauth_accounts with a UUID key and a polymorphic owner. Add HasOAuthAccounts to the owning model:
use Hypervel\Saloon\Traits\HasOAuthAccounts; class User extends Authenticatable { use HasOAuthAccounts; }
After exchanging an authorization code, save the returned credentials:
$account = $user->oauthAccounts()->create([ 'provider' => 'gitlab', 'provider_account_id' => $providerUserId, 'access_token' => $token->getAccessToken(), 'refresh_token' => $token->getRefreshToken(), 'expires_at' => $token->getExpiresAt(), 'scopes' => $token->getScopes(), ]); $request->authenticate($oauth->accountAuthenticator($account));
Access and refresh tokens use encrypted casts and are hidden from model serialization. Client secrets remain in your application configuration. Reconnect by updating the selected account's credential fields, and disconnect locally by deleting the account. Deletion does not revoke the provider grant. The same owner may have several grants for one provider account, so Saloon does not infer which row a reconnect should replace.
To customize the table, connection or token behavior, extend Hypervel\Saloon\OAuthAccount and select the model during provider boot, before using accounts:
use Hypervel\Saloon\OAuthAccounts; OAuthAccounts::useAccountModel(App\Models\OAuthAccount::class);
Override newOAuthAuthenticator() when persisted credentials need custom authentication behavior. The default creates a bearer authenticator. An account authenticator retains the account's storage identity, not a permanent copy of its token.
You may use another OAuth client by supplying a refresh callback. For example, a Socialite provider's refresh result can be adapted without changing Socialite:
use Hypervel\Saloon\Contracts\OAuthAuthenticator; use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; use Hypervel\Support\Facades\Date; $authenticator = $account->authenticator( refreshUsing: function (OAuthAuthenticator $current) use ($provider): OAuthAuthenticator { $token = $provider->refreshToken($current->getRefreshToken()); return new AccessTokenAuthenticator( accessToken: $token->token, refreshToken: $token->refreshToken, expiresAt: $token->expiresIn === null ? null : Date::now()->addSeconds($token->expiresIn)->toDateTimeImmutable(), scopes: $token->approvedScopes ?: null, ); }, );
Socialite's empty approved-scopes list does not distinguish an omitted field. Mapping it to null retains the account's stored scopes; adapters that know the provider explicitly cleared them may return []. Initial Socialite user results expose the raw token response when exact field presence matters.
Credential caching is disabled by default. Enable it in config/saloon.php to avoid database reads on ordinary authenticated calls:
'oauth_accounts' => [ 'cache' => [ 'enabled' => true, 'store' => 'redis', 'ttl' => 300, 'prefix' => 'saloon:oauth-accounts:', ], 'lock_store' => null, ],
The cache stores encrypted model attributes. Normal saves and deletes invalidate the entry after commit; reads inside an application transaction bypass shared caching. Custom account models retain their global scopes and query builders.
The renewal lock store defaults to the account cache store, then the framework default. It is required even when credential caching is disabled. Use Redis or a database store for deployments spanning hosts. File and Swoole stores are supported when their sharing scope covers every worker using the accounts. Worker-local, stack and failover stores are not supported for durable account credentials or renewal locks.
Quiet saves, bulk updates and raw queries have the usual Eloquent responsibilities. After such changes, call $account->clearCredentialCache(). Credential changes must also replace credential_revision, recompute refresh_at and clear obsolete refresh claims. Prefer normal model updates so these steps happen automatically.
The default response-cache key includes the bearer token, so renewed tokens no longer match old response entries. If you choose stable response-cache keys or scopes, preserve account and permission isolation yourself.
Saloon records a fixed early-renewal deadline when credentials are saved. A token without a refresh token remains usable until its actual expiry, then throws Hypervel\Saloon\Exceptions\OAuthAccountReconnectRequiredException. Catch this exception to prompt the user to reconnect. Tokens without expiry do not renew automatically.
Renewal rereads the account, records ownership of the attempt, then calls the provider without holding an idle database connection. The replacement is stored before it is applied to a request. A concurrent reconnect, deletion or replacement attempt cannot be overwritten by a late result. Inside a transaction on the account connection, Saloon uses an unexpired token when no attempt holds a refresh claim, deferring early renewal. Otherwise, it rejects renewal before waiting or contacting the provider.
If the returned access token has already expired, Saloon stores the replacement credentials and throws an UnexpectedValueException instead of using it. This preserves a rotated refresh token for a later attempt without immediately calling the provider again.
Transient connection failures, selected HTTP errors and temporary OAuth errors receive up to three attempts, with jittered delays capped at 250 and 500 milliseconds. Each attempt rereads current credentials. Longer Retry-After delays are returned to the caller for later scheduling. The connector convenience disables nested HTTP retries; custom refresh callbacks must also make one provider attempt per invocation.
An invalid_grant response is not retried. Saloon first checks for replacement credentials, then preserves the provider exception if the grant is still rejected. Your application decides how to reconnect or disable the account. invalid_client indicates client configuration needs attention; it does not require every user to reconnect. Transient exhaustion preserves the stored grant for a later attempt. Provider responses remain available through their normal exceptions.
The renewal lock lasts 120 seconds, with up to 120 seconds waiting to acquire it. A recorded attempt becomes recoverable after 300 seconds. Keep custom refresh callbacks and timeouts within those bounds; the default HTTP timeouts are 10 seconds connecting and 30 seconds total. Cancellation stops that caller and clears its owned claim where possible. Waiting callers may continue independently.
These measures cannot guarantee exactly one provider request after a crash or ambiguous network failure. A delayed request may overlap recovery, and providers that detect reuse of rotated refresh tokens may revoke the grant. Such accounts need reconnection. Saloon does not run detached refresh tasks or silently retry failed local persistence.
Applications that divide accounts by tenant or another ownership boundary may register a partition resolver during provider boot:
use Hypervel\Saloon\OAuthAccounts; OAuthAccounts::resolvePartitionUsing('tenant_id', fn () => $tenancy->currentKey());
Add the partition column and suitable indexes to your published migration. The callback should capture a worker-safe context service and resolve the current key when called. Missing context fails explicitly. New accounts receive that partition and cannot move to another partition. Normal saves and deletes are rejected outside the account's partition. Retained authenticators are checked against it on every use, including warm cache reads and after provider calls.
Your model or integration still owns application-wide query scoping; Saloon does not install a global Eloquent scope. Platform-owned accounts use an explicit platform partition. Cached client-credentials tokens use grant identity instead, allowing identical platform credentials to share a token. Keep merchant-specific headers on the request or connector.
Use the OAuth configuration's requestModifier callback to customize every token and user request:
use Hypervel\Saloon\Data\OAuthConfig; use Hypervel\Saloon\Http\Request; return new OAuthConfig( clientId: $this->clientId, clientSecret: $this->clientSecret, redirectUri: $this->redirectUri, requestModifier: function (Request $request): void { $request->withHeader('X-Application', 'billing'); }, );
The getAccessToken, refreshAccessToken, and getUser methods also accept a requestModifier callback for one operation:
$authenticator = $connector->getAccessToken( code: $code, requestModifier: function (Request $request): void { $request->withQueryParameters(['access_type' => 'offline']); }, );
For providers that require a different request body or token response format, override the protected resolveAccessTokenRequest, resolveRefreshTokenRequest, resolveUserRequest, createOAuthAuthenticatorFromResponse, or createOAuthAuthenticator method on the connector. These methods let you adapt the provider protocol without replacing the OAuth flow. A PKCE verifier is added to the request your resolveAccessTokenRequest method returns.
For example, you may return your own authenticator class. Authorization code connectors receive the access token, refresh token, expiry and scopes; client credentials connectors receive the access token, expiry and scopes:
use DateTimeImmutable; use Hypervel\Saloon\Contracts\OAuthAuthenticator; protected function createOAuthAuthenticator(string $accessToken, ?string $refreshToken = null, ?DateTimeImmutable $expiresAt = null, ?array $scopes = null): OAuthAuthenticator { return new SpotifyAuthenticator($accessToken, $refreshToken, $expiresAt, $scopes); }
Saloon pools send requests through bounded Hypervel coroutines. They replace promise-based asynchronous APIs and preserve the current application context in each child coroutine.
Create a pool through a connector and call send to collect successful responses. The iterable's keys are preserved, and the returned array follows input order rather than completion order:
$pool = $github->pool([ 'taylor' => new GetUser('taylorotwell'), 'hypervel' => new GetUser('hypervel'), ], concurrency: 5); $responses = $pool->send(); $responses['hypervel']->json();
The pool also accepts a lazy iterable or a producer callback that receives the connector:
$responses = $github->pool(function (Connector $connector) use ($usernames) { foreach ($usernames as $username) { yield $username => new GetUser($username); } }, concurrency: 10)->send();
The callback runs each time the pool is sent, so a pool whose callback returns a new iterable each time may be sent again. A generator passed directly can only be sent once.
The concurrency defaults to 5 and must be a positive integer. Each request is sent as the connector's send method would send it, so its retry policy, middleware, caching, and rate limits still apply. Scheduling blocks when the bound is full, so a lazy iterable does not create an unbounded queue of child coroutines.
You may also create an empty pool and configure it fluently before sending:
$responses = $github->pool() ->setRequests($requests) ->setConcurrency(10) ->withResponseHandler($responseHandler) ->withExceptionHandler($exceptionHandler) ->send();
If a response handler owns each result and you do not need a response array, call process. Successful responses are not retained:
$github->pool( requests: $requests, concurrency: 10, responseHandler: function (Response $response, int|string $key): void { ProcessImportedUser::dispatch($key, $response->json()); }, )->process();
Response and exception handlers run inside the request's child coroutine. Services used by those handlers must therefore be coroutine-safe.
You may handle request failures while allowing the remaining requests to finish:
$responses = $github->pool( requests: $requests, exceptionHandler: function (Throwable $exception, int|string $key): void { report($exception); }, )->send();
A failed response is a request failure: when the response would throw, such as a 4xx or 5xx status or a request your failure hooks mark as failed, the exception handler receives its request exception, and $exception->response() returns the response. A handled failure is omitted from the returned responses.
Without an exception handler, or when a response or exception callback fails, Saloon waits for every started child and then throws PoolException. The exception provides orchestrationFailure, failures, callbackFailures, and responses methods so no completed work or cause is lost. Its previous exception is the failure that stopped scheduling, otherwise the first request failure, or the first callback failure if no request failed, so reported pool failures show their cause.
To stop sending early, end the request generator. Requests that have already started, or that are waiting for a free slot, still run to completion.
Saloon can cache successful responses using Hypervel's cache stores. Caching is opt-in and may be declared by a connector or request.
To cache an integration's responses, implement Cacheable on its connector:
use DateInterval; use DateTimeInterface; use Hypervel\Saloon\Cache\Contracts\Cacheable; use UnitEnum; class GitHubConnector extends Connector implements Cacheable { // ... public function cacheFor(): DateInterval|DateTimeInterface|int { return 300; } public function cacheStore(): UnitEnum|string|null { return 'redis'; } }
A null store uses saloon.cache.store, which itself falls back to Hypervel's default cache store. A request that implements Cacheable supplies its duration and store before the connector.
An integer cache duration is measured in seconds. You may also return a DateInterval or DateTimeInterface instance.
GET, HEAD, OPTIONS, and QUERY requests are cacheable by default. To change which methods are eligible, override cacheableMethods on the connector, or on a request using the HasCaching trait. A request's list takes precedence over its connector's:
use Hypervel\Saloon\Enums\Method; protected function cacheableMethods(): array { return [Method::GET, Method::POST]; }
A response is written unless it failed, including failures defined by a request or connector's failure check. Mock responses and fixtures are cached like network responses, so a cached response is returned before the mock client is consulted. See Fixtures for keeping tests out of the cache.
The default cache key includes the connector and request classes, method, final URI, headers, cookies, authentication and certificate state, prepared body, and response-affecting transport options. This means a QUERY request's body is part of its identity. Responses cached from streaming bodies are buffered because the cache must retain their bytes.
Caching cannot be combined with Guzzle's sink option. A cache hit could not reproduce the sink's file or resource side effect. Export the returned response explicitly when you need both behaviors.
Add the HasCaching trait to a request when callers should be able to enable, bypass, or refresh caching for one operation:
use Hypervel\Saloon\Cache\Traits\HasCaching; class GetUser extends Request { use HasCaching; // ... }
The trait provides the following fluent methods:
$request->enableCaching(); $request->disableCaching(); $request->invalidateCache();
The request or its connector must implement Cacheable before these controls are used. invalidateCache removes the matching value and caches the new response.
To remove a cached response without sending the request, pass the connector to the request's clearCache method. A connector's clearCache method accepts any request, including one without the HasCaching trait:
$request->clearCache($connector); $connector->clearCache($request);
Clearing ignores disableCaching and the cacheable methods. It prepares the request as sending does, running its plugins, boot methods, and middleware, so it removes the value the request would read. SendingSaloonRequest listeners do not run, so any change a listener makes to the request's identity must also be made by a hook or middleware, or derived by a custom cache key.
To always keep a request type out of connector caching, override cachingEnabled on a request without the HasCaching trait. This is useful for live streams that must not be buffered:
public function cachingEnabled(): bool { return false; }
A connector may define a custom logical key by overriding cacheKey. A request may define one after adding the HasCaching trait:
use Hypervel\Saloon\Http\PendingRequest; protected function cacheKey(PendingRequest $pendingRequest): ?string { return 'github-user:' . $this->username; }
Saloon hashes custom keys before passing them to the cache backend, so raw credentials and tenant identifiers are not exposed in backend key names. A custom key must include every value that can change the successful response. It is required when a non-seekable request body is cached or when lower-level PSR or HTTP middleware changes response identity after Saloon finalizes the operation.
Applications that require an explicit tenant, account, or workspace partition may register a cache-scope resolver during application boot:
use App\Http\Integrations\Reports\ReportsConnector; use Hypervel\Saloon\Facades\Saloon; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Support\Facades\Context; public function boot(): void { Saloon::resolveCacheScopeUsing( function (PendingRequest $pendingRequest): ?string { if (! $pendingRequest->connector() instanceof ReportsConnector) { return null; } $tenantId = Context::get('tenant_id'); return is_string($tenantId) ? $tenantId : null; }, ); }
The callback runs when a cacheable operation resolves its key. It must resolve the current tenant at invocation time; do not capture one tenant while the worker boots. Returning null deliberately allows sharing for connectors where a separate scope is not needed.
Authentication state already separates tenant-owned credentials, and the final URI separates tenant-specific endpoints. A cache scope expresses an additional application policy, such as preventing tenants with shared platform credentials from sharing provider responses. It is applied to reads, writes, invalidation, clearing, and custom keys.
Saloon pagination iterates through pages returned by a remote API. It is separate from Hypervel's application pagination, which prepares local data for views and JSON responses.
A paginated request must implement the Paginatable marker contract. Create a paginator class for the remote API's pagination format. Saloon includes page, offset, cursor, link, and link header paginators. For another format, extend Paginator and implement applyPagination, isLastPage, and getPageItems, using the protected $pageNumber and $perPageLimit properties to build each page's request.
For page-number pagination, extend PagedPaginator and describe how to find the items and last page:
use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\Request; use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Pagination\Contracts\HasPagination; use Hypervel\Saloon\Pagination\Contracts\HasRequestPagination; use Hypervel\Saloon\Pagination\Contracts\Paginatable; use Hypervel\Saloon\Pagination\PagedPaginator; use Hypervel\Saloon\Pagination\Paginator; class ListUsers extends Request implements Paginatable { // Define the request method and endpoint... } /** @extends PagedPaginator<array<string, mixed>> */ class GitHubPaginator extends PagedPaginator { protected function isLastPage(Response $response): bool { return $response->json('page') >= $response->json('pages'); } protected function getPageItems(Response $response, Request $request): array { return $response->json('data'); } protected function getTotalPages(Response $response): int { return (int) $response->json('pages'); } } /** @implements HasPagination<array<string, mixed>> */ class GitHubConnector extends Connector implements HasPagination { // Define the connector base URL and defaults... /** @return Paginator<array<string, mixed>> */ public function paginate(Request $request): Paginator { if ($request instanceof HasRequestPagination) { return $request->paginate($this); } return new GitHubPaginator($this, $request); } }
Call the connector's paginate method, then iterate over responses or items:
$paginator = $github->paginate(new ListUsers) ->perPageLimit(100) ->maxPages(10); foreach ($paginator->items() as $user) { // ... } $users = $paginator->collect();
The HasPagination contract provides the conventional connector entry point. A request that needs its own paginator may implement HasRequestPagination and define paginate(Connector $connector): Paginator; the connector can delegate to it as shown above. Declare the request's item type with @implements HasRequestPagination<UserData> and @return Paginator<UserData> on its paginate method.
Iterating the paginator itself yields each page's response, and only the current page is kept in memory. A page response that would throw stops iteration with its request exception. The collect(false) method returns a lazy collection of page responses instead of items. To return data objects, map them in getPageItems and declare the paginator's item type with @extends PagedPaginator<UserData> (or the matching base class) to preserve it through items and collect. You may also inspect totalResults, request, and the zero-based iterator position returned by currentPage. Use startPage to configure the first remote page number.
Calling count($paginator) counts remote pages by requesting each page. It is not a metadata-only operation. Do not use iterator_count, which never loads a page and so cannot find the last one.
Override the protected query-name properties when an API uses different names:
protected string $pageName = 'currentPage'; protected string $perPageName = 'pageSize';
PagedPaginator defaults to page and per_page, and sends a page size only when a per-page limit is set. To send one by default, declare protected ?int $perPageLimit = 100; on your paginator. OffsetPaginator provides $limitName and $offsetName, defaulting to limit and offset; CursorPaginator provides $cursorName and $perPageName, defaulting to cursor and per_page. Override applyPagination(Request $request): Request for a protocol that needs a different request structure.
During sequential pagination, Saloon throws a PaginationException when it detects a loop. Page and offset paginators compare response bodies and stop after five consecutive identical pages. Link and cursor paginators also compare the next URL or cursor, so empty pages can continue when the API supplies an advancing continuation. They stop as soon as a page and its continuation repeat one of the previous four pages, since the next request would repeat an earlier one. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring protected bool $detectInfiniteLoop = false; on your paginator.
The check runs after response middleware and item mapping. If your API adds a value that changes on every request, such as a request ID, override getBodyChecksum to compare only the page contents. Link and cursor paginators still include continuation information when using this override:
protected function getBodyChecksum(Response $response): string { return hash('xxh128', json_encode($response->json('data'))); }
If a request implements MapPaginatedResponseItems, its mapPaginatedResponseItems method takes precedence over the paginator's item mapping. Declare @implements MapPaginatedResponseItems<UserData> with the same item type as its paginator. Mapping runs once per fetched page, after all response middleware, and totalResults counts these final items.
Extend OffsetPaginator for APIs that use limit and offset. A per-page limit must be configured before iteration, and the protected getOffset method returns the current page's offset, such as for comparison with a total in isLastPage. Extend CursorPaginator for APIs where each response supplies the next cursor, and implement getNextCursor.
Cursors may be strings, numbers, booleans, or null. The default query encoding omits null and sends booleans as 1 or 0. Override applyPagination when your API requires another encoding, and use isLastPage to determine when pagination ends.
Cursor pagination is always sequential because a later request depends on the previous response. Rewinding a paginator clears its iterator state and begins again at the configured start page.
For APIs that return pagination URLs in the response body, extend LinkPaginator and implement getLinks. Return the next and last links when they are available, using resolveLink to resolve each URL against the current request:
use Hypervel\Saloon\Http\Request; use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Pagination\LinkPaginator; use Psr\Http\Message\UriInterface; /** @extends LinkPaginator<array<string, mixed>> */ class UserPaginator extends LinkPaginator { protected function getLinks(Response $response, UriInterface $currentUri): array { $links = []; foreach (['next', 'last'] as $relation) { $target = $response->json("links.pages.{$relation}"); if ($target !== null) { $links[$relation] = $this->resolveLink($target, $currentUri); } } return $links; } protected function getPageItems(Response $response, Request $request): array { return $response->json('data'); } }
You may also read a URL from a response header in getLinks and pass it to resolveLink. Pagination links must use the same scheme, host, and port as the current request and must not contain user information. A different path is allowed, so an API may return links using its canonical resource IDs.
The first request uses your configured page and per-page parameters. For each later request, the paginator follows the complete next URL, replacing any explicit raw query string with the link's query. Repeated parameter names are preserved. Parameters configured separately on the request or connector, including authentication, still take precedence. If you override applyPagination, call applyNextLink($request) for sequential continuation requests.
Iteration ends when the response has no next link. If the API also supplies a last link containing a page number, you may use pool to request the remaining pages concurrently. Pooled requests use the original endpoint with your configured page names and perPageLimit. Cursor-only links must be followed sequentially. Invalid or contradictory last-page numbers are rejected when pooling.
Extend LinkHeaderPaginator for APIs that return pagination links in the HTTP Link header:
use Hypervel\Saloon\Http\Request; use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Pagination\LinkHeaderPaginator; /** @extends LinkHeaderPaginator<array<string, mixed>> */ class RepositoryPaginator extends LinkHeaderPaginator { protected function getPageItems(Response $response, Request $request): array { return $response->json(); } }
LinkHeaderPaginator uses the same navigation and pooling behavior as LinkPaginator. It reads the next and last relations by default. You may override its protected $nextRelation and $lastRelation properties when an API uses different relation names. Malformed header syntax or conflicting pagination links throw a PaginationException.
Page and offset paginators may send independently addressable pages through a bounded coroutine pool when getTotalPages is implemented:
$responses = $paginator->pool( concurrency: 5, responseHandler: function (Response $response, int $position): void { // ... }, );
The first page is sent before the remaining range is scheduled. Response keys and callback positions use the paginator's zero-based iterator position. maxPages is honored, and pool failures retain the first response and all other completed work.
A first-page mapping error propagates before scheduling any remaining requests. For later pages, mapping errors appear in PoolException::callbackFailures; those pages are not counted and their response handlers are not called. A response-handler error also appears there, but its successfully mapped page remains counted.
Saloon uses Hypervel's rate limiter for atomic admission policies and provider-directed cooldowns. Policies may be declared by a connector, request, or both.
Add HasRateLimits and return one or more immutable admission policies:
use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Limit; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Saloon\RateLimit\Traits\HasRateLimits; class GitHubConnector extends Connector { use HasRateLimits; /** @return list<AdmissionPolicy> */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [ Limit::perMinute(60)->by('github'), ]; } }
A policy may be a fixed, calendar or sliding window, or a leaky bucket. For example, a quota that resets at the end of each minute or day suits a calendar window, while an API that refills capacity continuously suits a leaky bucket. Give each user or API key its own limit with a distinct by key.
By default, a denied policy throws RateLimitReachedException before reaching the network. The exception's policy and result methods return the denied policy and its decision, so $exception->result()->retryAfter() gives the number of seconds to wait. Override waitForRateLimits to sleep until capacity is available instead. It receives the denied policy and its decision, so you may wait out a short burst limit while still throwing for a daily quota:
use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Contracts\Decision; use Hypervel\RateLimiter\Cooldown; protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { return $result->retryAfter() <= 10; }
Limits are stored in the rate-limiter store named by the saloon.rate_limiter.store configuration value, or the rate limiter's default store when it is null. A connector or request may return another configured store name from resolveRateLimitStore. Fakes and cache hits do not consume capacity.
Saloon consumes each resource's policies together, subject to the documented Redis Cluster limitation. Connector and request limits are separate: the connector is admitted first, so its charge remains if the request's limits deny the operation. After waiting for capacity, Saloon checks the resource's cooldown again before retrying its policies.
The pending request allows policies to use operation and tenant context. A single operation may consume a tenant-plan policy and a service-wide provider policy:
protected function resolveRateLimits(PendingRequest $pendingRequest): array { $tenant = tenant(); return [ Limit::perMinute($tenant->plan->apiRequestsPerMinute) ->by('github'), Limit::perMinute(5_000) ->by('github-service') ->globally(), ]; }
Register a named key-scope resolver during application boot to partition Saloon's non-global policies by the current tenant:
use Hypervel\Support\Facades\Context; use Hypervel\Support\Facades\RateLimiter; RateLimiter::resolveKeyScopeUsing(function (string $limiter): ?string { if (! str_starts_with($limiter, 'saloon:')) { return null; } $tenantId = Context::get('tenant_id'); return is_string($tenantId) ? $tenantId : null; });
Saloon limiter names always begin with saloon:. Restricting the callback prevents an integration-specific tenant scope from changing unrelated route or queue limiter keys. Policies marked globally deliberately bypass named key scopes, allowing the whole application to share a provider limit across tenants, workers, and servers when the selected store is shared.
When a rate-limited connector or request receives a 429 response, Saloon records the provider's cooldown before response middleware runs. The Retry-After header sets its length. If the header is missing or invalid, the cooldown lasts 60 seconds, while a delay that has already passed records no cooldown. A later operation checks this cooldown before consuming its configured admission policies.
By default, each connector or request has its own cooldown. If a provider applies cooldowns per account or credential, override resolveRateLimitCooldownKey with that stable provider identity:
protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest): string { return 'account:' . $this->providerAccountId; }
Do not infer this key from a hostname or assume every provider limits by token. Use the boundary documented by the provider.
You may override resolveRateLimitCooldown to support a provider-specific response or clamp a provider-defined maximum. Returning null records no cooldown:
use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Limit; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\RateLimit\Traits\HasRateLimits; class GitHubConnector extends Connector { use HasRateLimits { resolveRateLimitCooldown as protected resolveDefaultRateLimitCooldown; } /** @return list<AdmissionPolicy> */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [Limit::perMinute(60)->by('github')]; } protected function resolveRateLimitCooldown(Response $response): ?int { $seconds = $this->resolveDefaultRateLimitCooldown($response); return $seconds === null ? null : min($seconds, 3_600); } }
A 429 response is returned through normal error handling. Recording a cooldown never resends the response recursively. If the normal retry policy requests another attempt, that attempt must first pass the recorded cooldown. To wait out the cooldown and send the request again, retry 429 responses and wait for cooldowns:
use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Contracts\Decision; use Hypervel\RateLimiter\Cooldown; use Hypervel\Saloon\Data\RetryPolicy; use Hypervel\Saloon\Exceptions\Request\FatalRequestException; use Hypervel\Saloon\Exceptions\Request\RequestException; protected function defaultRetryPolicy(): ?RetryPolicy { return new RetryPolicy( times: 2, when: fn (FatalRequestException|RequestException $exception): bool => $exception instanceof RequestException && $exception->status() === 429, ); } protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { return $policy instanceof Cooldown; }
Saloon records a resource's limits and cooldowns under its class name. When several connectors or requests use the same provider quota, override resolveRateLimiterName on each of them to return the same name:
protected function resolveRateLimiterName(): string { return 'acme'; }
Resources with the same name share a policy's state when they declare the same policy and use the same rate-limiter store and key scope. A 429 received by one of them pauses the others too, unless their cooldown keys differ.
The Saloon facade's inspectRateLimit method checks a resource's policy or cooldown without consuming it, using the same store and limiter name as a request:
use Hypervel\RateLimiter\Cooldown; use Hypervel\RateLimiter\Limit; use Hypervel\Saloon\Facades\Saloon; $result = Saloon::inspectRateLimit($connector, Limit::perMinute(60)->by('github')); if ($result->remaining() <= $result->limit() * 0.2) { // 20% or fewer of this minute's requests remain... } $cooldown = Saloon::inspectRateLimit($connector, Cooldown::for(null));
Pass the same policy your resource declares. Putting shared policies in a method keeps them identical.
Override rateLimitingEnabled to skip a resource's limits for an operation. A disabled operation consumes no capacity, ignores active cooldowns, and records no cooldown from its response:
protected function rateLimitingEnabled(PendingRequest $pendingRequest): bool { return ! $pendingRequest->request() instanceof ImportRequest; }
Disabling a request's limits does not disable its connector's limits. Returning no policies still applies cooldowns.
Queued jobs may use ReleaseOnRateLimit to release themselves for the delay reported by a denied admission policy or server cooldown:
use Hypervel\Saloon\RateLimit\Queue\ReleaseOnRateLimit; public function middleware(): array { return [new ReleaseOnRateLimit]; }
The middleware catches only RateLimitReachedException. Other exceptions continue through the normal queue failure path. Each release consumes a job attempt, so configure the job's $tries or retryUntil method for the amount of time it may continue waiting on a limited API.
The appropriate connector lifetime depends on who owns the remote integration configuration.
When every tenant uses the same platform-owned credential and endpoint, bind one stable connector and apply tenant-specific request values at the operation boundary:
$response = $github->send( (new CreateReport($report))->withHeader('X-Tenant', $tenant->externalId), );
When each tenant supplies its own credentials or endpoint, construct a connector from that tenant's immutable values:
use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Http\Auth\TokenAuthenticator; use Hypervel\Saloon\Http\Connector; class TenantCrmConnector extends Connector { public function __construct( private readonly string $baseUrl, private readonly string $token, ) { } public function resolveBaseUrl(): string { return $this->baseUrl; } protected function defaultAuth(): ?Authenticator { return new TokenAuthenticator($this->token); } } $crm = new TenantCrmConnector( baseUrl: $tenant->crm_url, token: $tenant->crm_token, ); $response = $crm->send(new ListContacts);
Warning
Validate tenant-supplied base URLs before storing them. Saloon requires an HTTP or HTTPS URL, but your application must decide which external hosts it trusts.
Do not mutate application config or a worker-shared connector during request handling. The connector's authentication and final URI become part of the default cache identity, so different BYO credentials and tenant endpoints are isolated without extra cache configuration.
For shared credentials, decide whether provider responses may be shared. Register a cache scope only when your application requires another tenant boundary. Likewise, use a rate-limiter key scope for tenant-plan limits and a globally policy for provider-wide capacity.
Real Saloon network requests flow through Hypervel's HTTP client and are recorded by the Telescope HTTP watcher. Every recorded request includes the saloon tag.
Use withTelescopeTags to add operation-specific tags, or withoutTelescope to suppress one request:
$request->withTelescopeTags(['billing', 'stripe']); $request->withoutTelescope();
A connector may apply either method from boot when the setting belongs to every operation:
public function boot(PendingRequest $pendingRequest): void { $pendingRequest->withTelescopeTags(['github']); }
Saloon fakes and cache hits do not reach the HTTP watcher and are therefore not recorded as network requests. These methods remain safe when Telescope is disabled or not installed.
Saloon dispatches SendingSaloonRequest after ordinary request middleware and SentSaloonRequest before ordinary response middleware. The events expose the pending request, and the sent event also exposes the response:
use Hypervel\Saloon\Events\SentSaloonRequest; class RecordProviderUsage { public function handle(SentSaloonRequest $event): void { // $event->pendingRequest // $event->response } }
These domain events are dispatched for network responses, Saloon fakes, and cache hits. Hypervel HTTP events and Telescope recording apply only when the request reaches the HTTP client.
Connectors, requests, pending requests, and responses support macros. Register macros during application boot:
use Hypervel\Saloon\Http\Request; Request::macro('forTenant', function (string $tenantId) { return $this->withHeader('X-Tenant', $tenantId); });
Macros remain registered for the worker lifetime. Hypervel automatically clears Saloon macros between tests.
Saloon provides a strict mock client, response sequences, fixtures, request recording, and PHPUnit assertions. Saloon fakes operate at the integration layer, after the response cache is checked and before admission policies and Hypervel HTTP transport.
Use the Saloon facade to fake responses for the current test application:
use App\Http\Integrations\GitHub\Requests\GetUser; use Hypervel\Saloon\Facades\Saloon; use Hypervel\Saloon\Http\Faking\MockResponse; Saloon::fake([ GetUser::class => MockResponse::make([ 'id' => 1, 'login' => 'hypervel', ]), ]);
The make method accepts a body, status code, and headers. An array body is encoded as JSON, while a string body is returned as it is:
MockResponse::make(['message' => 'Not Found'], 404, [ 'Content-Type' => 'application/json', ]);
To simulate a failed connection, pass an exception to the mock response's throw method. Saloon reports a ConnectionException as a FatalRequestException, just like a real connection failure:
use Hypervel\Http\Client\ConnectionException; Saloon::fake([ GetUser::class => MockResponse::make()->throw(new ConnectionException('Connection refused')), ]);
The fake belongs to the test application, so each test starts without one. Calling fake again adds responses to the same mock client, so a test may extend the fakes registered in its setUp method. To replace the global mock client instead, pass a MockClient instance to fake.
Responses may be matched by request class, connector class, or wildcard URL. Request matches take precedence over connector matches, followed by URL matches and sequence responses:
Saloon::fake([ GetUser::class => MockResponse::make(['login' => 'hypervel']), GitHubConnector::class => MockResponse::make(['ok' => true]), 'https://api.github.com/repos/*' => MockResponse::make([], 404), ]);
A URL pattern may leave out the scheme and host, such as 'api.github.com/repos/*' or '/user', and matches the URL with or without its query string. A pattern that includes a query string, such as '/users?page=2', matches only that encoded query. Assertions and allowStrayRequests use the same patterns.
Each matching value may also be a callback that receives the pending request and returns a mock response or fixture. A lower-level Http::fake() still prevents a network request after the Saloon lifecycle reaches Hypervel's HTTP client. The response is recorded by the HTTP client and isMocked() returns false. When no Saloon mock client is active, Saloon facade assertions do not include the response.
You may attach a mock client to one request using withMockClient, or pass it as the second argument to Connector::send. An explicitly supplied client takes precedence over a request client, which takes precedence over the facade's global test client. Mock responses are matched after request middleware has run and the URL is final, so URL matches see any changes middleware made.
A local mock client is useful when testing an SDK's requests directly. It records every request sent with it, so you may make assertions on it:
use Hypervel\Saloon\Http\Faking\MockClient; use Hypervel\Saloon\Http\Faking\MockResponse; $mockClient = new MockClient([ CreateIssue::class => MockResponse::make(['id' => 1], 201), ]); $github->send(new CreateIssue('Bug report'), $mockClient); $mockClient->assertSent(fn (CreateIssue $request) => $request->body() === ['title' => 'Bug report']);
Mock clients are strict by default. An unmatched request throws NoMockResponseFoundException instead of reaching the network.
To prevent every unmocked HTTP request from reaching the network, including a Saloon request sent without a mock client, use Hypervel HTTP's application-wide guard:
use Hypervel\Support\Facades\Http; Http::preventStrayRequests();
Http::preventStrayRequests() guards the transport even when a Saloon mock client allows an unmatched request to continue. A Saloon mock client's preventStrayRequests and allowStrayRequests methods only decide whether unmatched requests may reach that transport boundary.
Use allowStrayRequests when selected unmatched requests should continue through the normal cache, rate-limit, and network lifecycle:
$client = Saloon::fake([ GetUser::class => MockResponse::make(['login' => 'hypervel']), ]); $client->allowStrayRequests([ 'https://api.github.com/rate_limit*', ]);
Calling allowStrayRequests() without an array permits every unmatched request. Use preventStrayRequests() to restore strict behavior.
Numeric response entries are consumed in registration order:
Saloon::fake([ MockResponse::make(['page' => 1]), MockResponse::make(['page' => 2]), ]);
When no class or URL response matches, Saloon consumes the next sequence response. An exhausted strict mock client throws an exception.
The facade provides assertions for recorded Saloon requests. Responses served from the response cache are not recorded, because nothing was sent:
Saloon::assertSent(GetUser::class); Saloon::assertSent('https://api.github.com/users/*'); Saloon::assertNotSent(DeleteUser::class); Saloon::assertSentCount(1); Saloon::assertSentCount(1, GetUser::class); Saloon::assertNothingSent();
You may inspect the request and response with a closure. A request type or union filters the recorded requests before the closure is invoked:
Saloon::assertSent(function (GetUser $request, Response $response): bool { return $request->username === 'hypervel' && $response->successful(); });
The MockClient also provides assertSentInOrder, recorded, lastRequest, lastPendingRequest, and lastResponse.
Fixtures record a real response on first use and replay it on later requests:
use Hypervel\Saloon\Http\Faking\Fixture; Saloon::fake([ GetUser::class => new Fixture('github/users/hypervel'), ]);
You may also create a fixture using MockResponse::fixture('github/users/hypervel'). By default, fixtures are stored as JSON files under tests/Fixtures/Saloon, so this fixture is written to tests/Fixtures/Saloon/github/users/hypervel.json. To record a fixture again, delete its file and run the test.
Fixture names use portable path segments separated by forward slashes. Absolute paths, backslashes, empty segments, and . or .. segments are rejected. Lowercase names avoid fixtures going missing when a test suite moves between case-insensitive and case-sensitive filesystems.
When Http::preventStrayRequests() is active, recording a missing fixture is a stray request and throws. Allow the API's URLs while recording, such as by calling Http::allowStrayRequests(['https://api.github.com/*']).
To redact sensitive response data, extend Fixture and define header, JSON, or regular-expression replacement rules:
class GitHubFixture extends Fixture { protected function defineSensitiveHeaders(): array { return ['set-cookie' => '[redacted]']; } protected function defineSensitiveJsonParameters(): array { return ['token' => '[redacted]']; } protected function defineSensitiveRegexPatterns(): array { return ['/secret=[^&]+/' => 'secret=[redacted]']; } }
Header rules apply to each value of a matching header, so a closure receives one value and returns its replacement. Invalid or failed regular expressions prevent the fixture from being written. The rules change the stored file, so the test that records a fixture still receives the real response. A fixture class may also override defineName to return its name, so it can be created without arguments. You may use merge or through to adjust a recorded JSON object or array during replay, and withContext to store additional fixture metadata.
Mock responses and fixtures are cached like network responses. Once a cacheable request has been cached, later sends return the cached response instead of the next mock response or an updated fixture file, and a missing fixture is not recorded. Call withoutCache on the mock client to keep its requests from reading, writing, or invalidating the cache:
Saloon::fake([ GetUser::class => new Fixture('github/users/hypervel'), ])->withoutCache();
Tests may override the fixture directory and missing-fixture behavior:
Saloon::fixturePath(base_path('tests/Fixtures/Api')); Saloon::throwOnMissingFixtures();
These are tests-only settings and are cleared when the test application is destroyed. To fail on missing fixtures in every run, such as in CI, set the saloon.fixtures.throw_on_missing configuration option instead.
To publish Saloon's configuration file, use the saloon-config tag:
php artisan vendor:publish --tag=saloon-config
The configuration file contains the fixed HTTP connection, default cache and rate-limiter stores, fixture settings, and generated integration path and namespace.
You may publish the generator stubs using the saloon-stubs tag:
php artisan vendor:publish --tag=saloon-stubs
Set saloon.integrations_path to change where generated files are written. When the path is inside your application directory, generated classes follow it beneath your application's namespace, so app_path('Integrations') produces classes in App\Integrations. For a path outside the application directory, also set saloon.integrations_namespace to the namespace Composer loads from that path.
Hypervel Saloon keeps the connector, request, middleware, authentication, response, testing, OAuth 2, caching, pagination, and rate-limit concepts of Saloon while using Hypervel's framework services directly. When porting an existing integration, note the following differences:
- The Laravel plugin and the cache, pagination, and rate-limit plugins are built in, using Hypervel's cache, collections, coroutine, and rate-limiter services.
- Hypervel HTTP is the only transport. Requests are sent through a named HTTP connection instead of a sender, and there is no process-global
Configclass; register global middleware throughSaloon::middleware(). - Requests and pending requests use the HTTP client's method names, such as
withHeader,withQueryParameters,withOptions,timeout, andwithToken, and their getters return plain values instead of upstream's repositories. - Connectors are read-only, since one connector may be shared by concurrent requests. Operation-specific authentication, middleware, debugging, mocks, and retries belong to requests, the
sendcall, or the connector'sbootmethod. - Retries use
retryanddefaultRetryPolicyinstead of the$tries,$retryInterval,$useExponentialBackoff, and$throwOnMaxTriesproperties. - Coroutine-native pools replace
sendAsyncand promises. Paginator concurrency uses the same bounded pool. - Responses extend Hypervel HTTP responses. Request exceptions extend the HTTP client's
RequestExceptionrather thanSaloonException, so catchRequestExceptionfor failed responses. - Mock clients are matched when the request is sent, after request middleware has run. Fixture settings are configured through the
Saloonfacade and configuration file, and application-wide stray-request protection usesHttp::preventStrayRequests(), which also applies to fixture recording. - OAuth 2 configuration is immutable, and authorization URLs return their paired state.
- NTLM authentication is not included.
- Rate limits use Hypervel
AdmissionPolicyinstances instead of Saloon's mutable limit and store abstractions. - The
xmlReaderresponse method is not included. Use thexmlordommethods, or install XML Wrangler and pass$response->toPsrResponse()toXmlReader::fromPsrResponse().
The package README documents API differences and their Hypervel equivalents.
Hypervel Saloon began as a port of Saloon and has been adapted for Hypervel's framework architecture and coroutine runtime.