Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Saloon Laravel Package

saloonphp/saloon

Saloon is a PHP package for building beautiful, class-based API integrations. Define connectors and requests, then handle authentication, caching, retries, pagination, and testing through a clean, framework-agnostic API that works with Laravel or any PHP project.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

  1. Installation

    composer require saloonphp/saloon
    

    In Laravel the Saloon\Laravel\SaloonServiceProvider is auto-discovered — no manual config/app.php entry needed. Publish the config if you want to tweak defaults:

    php artisan vendor:publish --tag=saloon
    
  2. First Connector — one class per third-party API, centralising base URL, auth and headers:

    namespace App\Http\Integrations\Stripe;
    
    use Saloon\Http\Connector;
    use Saloon\Traits\Plugins\AcceptsJson;
    use Saloon\Traits\Plugins\HasApiVersion;
    
    class StripeConnector extends Connector
    {
        use AcceptsJson;
        use HasApiVersion;
    
        public function resolveBaseUrl(): string
        {
            return 'https://api.stripe.com/v1';
        }
    
        /** New in v4.5.0 — version is applied to every request from this connector. */
        protected function resolveApiVersion(): string
        {
            return config('services.stripe.version', '2024-06-20');
        }
    
        protected function defaultHeaders(): array
        {
            return [
                'Authorization' => 'Bearer ' . config('services.stripe.secret'),
            ];
        }
    }
    
  3. First Request — a Request is a value object describing one endpoint:

    namespace App\Http\Integrations\Stripe\Requests;
    
    use Saloon\Enums\Method;
    use Saloon\Http\Request;
    
    class GetCustomerRequest extends Request
    {
        protected Method $method = Method::GET;
    
        public function __construct(protected string $customerId) {}
    
        public function resolveEndpoint(): string
        {
            return '/customers/' . $this->customerId;
        }
    }
    
  4. Send It

    $connector = app(StripeConnector::class);
    $response  = $connector->send(new GetCustomerRequest('cus_123'));
    
    $customer = $response->json();       // array
    $response->status();                 // int
    $response->throw();                  // raise on 4xx/5xx
    

First Use Case: Pinning an API Version

The most common reason to reach for HasApiVersion is a provider that ships dated versions (Stripe, Shopify, Notion). Add the trait to the connector, resolve the version from config/env, and the header is attached to every request — no per-request boilerplate, and QA/staging can point at an older version via .env without touching code.

Where to Look First

  • Docs — docs.saloonphp.com: Connectors, Requests, Authentication, Testing.
  • Release notes — github.com/saloonphp/saloon/releases; v4.5.0 adds HasApiVersion.
  • Laravel glue — Saloon\Laravel\SaloonServiceProvider (container bindings, mocking helpers in tests).
  • Existing integrations — look for app/Http/Integrations/* first; Saloon's own docs use that convention and it keeps connectors out of App\Services.

Implementation Patterns

1. Connector-Driven Architecture

One connector per API; requests stay dumb. Keep credentials in config/services.php, never in request classes.

class GitHubConnector extends Connector
{
    use AcceptsJson, HasTimeout;

    public function resolveBaseUrl(): string
    {
        return 'https://api.github.com';
    }

    protected function defaultAuth(): Authenticator
    {
        return new BearerAuthenticator(config('services.github.token'));
    }

    protected function defaultHeaders(): array
    {
        return ['Accept' => 'application/vnd.github+json'];
    }
}

Resolve it from the container so tests and Octane share one instance:

$this->app->singleton(StripeConnector::class);
$stripe = app(StripeConnector::class);

2. Request-Level Customisation

Anything declared on the connector can be overridden per request: resolveEndpoint(), $method, defaultHeaders(), defaultAuth(), defaultBody(), $tries. Use this for the one endpoint that needs a different auth scheme or a longer timeout.

class UploadDocumentRequest extends Request
{
    protected Method $method = Method::POST;

    public function resolveEndpoint(): string
    {
        return '/documents';
    }

    protected function defaultBody(): array
    {
        return ['name' => $this->name, 'contents' => base64_encode($this->bytes)];
    }

    public function resolveApiVersion(): string   // per-request version pin
    {
        return '2024-01-01';
    }
}

3. Versioning with HasApiVersion (v4.5.0)

  • Attach the trait to the connector for a global default; override resolveApiVersion() on individual requests when an endpoint lags behind.
  • Keep the version in config (services.stripe.version) so it's a one-line .env change, not a deploy.
  • Version strings are opaque — pass exactly what the provider documents (2024-06-20, 2023-10-16, v2). Don't normalise or add prefixes.
// Global default on the connector, one endpoint pinned back
class ListInvoicesRequest extends Request
{
    public function resolveApiVersion(): string
    {
        return '2023-08-16'; // legacy endpoint not yet migrated
    }
}

Test the version is actually sent — assert it on the recorded request rather than trusting the trait:

$connector->withMockClient($mock = new MockClient([
    ListInvoicesRequest::class => MockResponse::make(['data' => []]),
]));

$connector->send(new ListInvoicesRequest());

expect($mock->getLastRequest()->headers()->get('Api-Version'))->toBe('2023-08-16');

4. Authentication Strategies

Built-in authenticators: BearerAuthenticator, BasicAuthenticator, HeaderAuthenticator, QueryAuthenticator, CertificateAuthenticator, and MultiAuthenticator for combining them.

For OAuth2 prefer the grant traits over hand-rolling a token cycle:

use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\OAuth2\ClientCredentialsGrant;

class GitHubConnector extends Connector
{
    use ClientCredentialsGrant;

    public function resolveBaseUrl(): string { return 'https://api.github.com'; }

    protected function defaultOauthConfig(): OAuthConfig
    {
        return OAuthConfig::make()
            ->setClientId(config('services.github.client_id'))
            ->setClientSecret(config('services.github.client_secret'))
            ->setTokenEndpoint('/login/oauth/access_token');
    }
}

Persist tokens in the cache (Saloon\Contracts\AccessTokenRepository / RefreshTokenRepository) so queue workers and Octane workers reuse them instead of re-authenticating per job.

5. Cross-Cutting Concerns: Plugins, Retries, Timeouts

Instead of middleware, compose traits on the connector:

use Saloon\Traits\Plugins\AcceptsJson;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
use Saloon\Traits\Plugins\HasTimeout;
use Saloon\Traits\Plugins\HasApiVersion;

class StripeConnector extends Connector
{
    use AcceptsJson, AlwaysThrowOnErrors, HasApiVersion, HasTimeout;

    protected int $tries = 3;
    protected int $retryInterval = 500;      // ms
    protected bool $throwOnMaxTries = true;
    protected int $connectTimeout = 10;
    protected int $requestTimeout = 30;

    public function handleRetry(FatalRequestException|RequestException $exception, Request $request): bool
    {
        return $exception instanceof FatalRequestException   // only network blips
            || ($exception instanceof RequestException && $exception->getResponse()->serverError());
    }
}

For heavy GET traffic, saloonphp/cache-plugin gives you per-request response caching with TTLs.

6. Testing with MockClient

use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;

it('lists invoices for the pinned version', function () {
    $mock = new MockClient([
        ListInvoicesRequest::class => MockResponse::make(['data' => [['id' => 'in_1']]], 200),
    ]);

    $connector = (new StripeConnector)->withMockClient($mock);
    $response = $connector->send(new ListInvoicesRequest);

    expect($response->json('data.0.id'))->toBe('in_1');
    $mock->assertSent(ListInvoicesRequest::class);
});

MockClient::global([...]) covers a whole suite; always assert what was sent as well as what came back — version headers, auth headers and body shape are exactly where integrations silently rot.

7. Error Handling

use Saloon\Exceptions\Request\ClientException;
use Saloon\Exceptions\Request\ServerException;
use Saloon\Exceptions\Request\FatalRequestException;

try {
    $response = $connector->send(new CreateChargeRequest($payload));
} catch (ClientException $e) {          // 4xx
    report($e);                          // log full request/response
    throw new PaymentFailedException($e->getResponse()->json('error.message'));
} catch (ServerException|FatalRequestException $e) {
    // already retried by the connector; let it bubble
    throw $e;
}

Convert provider errors into your own domain exceptions at the connector/request boundary — callers shouldn't know about Stripe's envelope shape. Use $response->failed(), $response->clientError(), $response->serverError() for non-throwing flows.

8. Dynamic Requests & Laravel Integration

Build request objects at runtime from user input or DB rows; keep the class list small and explicit.

$connector->send(new GetCustomerRequest($order->stripe_customer_id));

Wrap API calls in queued jobs (ShouldQueue) with the connector resolved from the container — retries belong to the queue, while Saloon's $tries handles transient HTTP failures inside a single attempt. Keep them low so you don't multiply attempts.


Gotchas and Tips

  • HasApiVersion needs an implementation. Adding the trait without defining resolveApiVersion() (or a sensible default) will blow up at send time. Define it once on the connector and override only where legacy endpoints demand it.
  • Confirm the header name and override hooks against the current docs before shipping — versioning providers differ (header vs. query string vs. URL path). If your provider expects ?version= or /v2/, the trait alone won't do it; implement the placement in the request instead.
  • $response->status() is a method, not a property. A lot of copy-pasted snippets get this wrong.
  • Nothing throws by default. Without AlwaysThrowOnErrors or an explicit $response->throw(), a 500 is just an array-less response that fails downstream in confusing ways.
  • Retries don't cover 4xx by default. Override handleRetry() if you need it; but never retry non-idempotent POSTs without an idempotency key header.
  • Trailing/leading slashes. resolveBaseUrl() should have no trailing slash, resolveEndpoint() should start with one — otherwise you get //customers.
  • Mocking is test-only. MockClient left wired in a service provider will happily return fixtures in production. Bind it in TestCase, not AppServiceProvider.
  • Octane/singletons and tokens. Long-lived workers keep connector instances alive; if you cache tokens on the connector, make sure refresh logic is idempotent and stored externally (cache/db) so concurrent workers don't stampede the token endpoint — add a lock around refresh.
  • Version drift is invisible. A connector with no pinned version silently follows whatever the provider's default is. Pin explicitly, log the version once at boot, and add a test asserting the header — that's the only thing that catches a provider changing defaults.
  • Log the request ID the provider returns (Request-Id, x-request-id) in your exception handler — it's the difference between a 10-minute and a 2-hour support ticket.
  • Extension points worth knowing: defaultHeaders(), defaultAuth(), defaultQuery(), defaultBody(), handleRetry(), plus plugin traits for caching (saloonphp/cache-plugin), rate limiting (saloonphp/rate-limit-plugin) and logging (saloonphp/laravel-plugin HTTP client events). Prefer a trait over copying boilerplate into every connector.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource