CakePHP Saloon Plugin
Introduction
The Saloon plugin integrates Saloon v4 — a powerful, modern PHP library for building API integrations and SDKs — with CakePHP 5. Saloon provides an elegant, object-oriented approach to HTTP client interactions with support for authentication, request/response handling, testing, pagination, caching, and rate limiting.
This plugin brings all of Saloon's features to CakePHP while adding framework-specific conveniences. The plugin includes automatic CakePHP event dispatching for every HTTP request and response, CLI generators via Bake commands for quickly scaffolding connectors and requests, and comprehensive testing helpers that integrate with CakePHP's test suite. Configuration follows standard CakePHP patterns, and the plugin provides bridges to CakePHP's cache system for response caching and rate limiting. Optional global middlewares also feed CakePHP's Speculum and Rhythm monitoring plugins with zero extra wiring.
Supported Features
This plugin supports all core Saloon v4 features including object-oriented connector and request architecture, multiple authentication methods (Token, Basic, OAuth2, and custom authenticators), comprehensive request body support (JSON, Multipart, XML, Form, String, Stream), and flexible response handling (JSON, XML, DTOs, streaming, file downloads).
The plugin provides complete testing capabilities with mock client support, fixtures, and assertions. It includes pagination strategies for paged, offset, cursor, and custom implementations. Response caching with TTL support is available, along with flexible rate limiting strategies using multiple backends. The plugin supports async requests and connection pools for concurrency, request/response pipeline customization via middleware, and built-in debugging tools. Full PSR-7, PSR-17, and PSR-18 compatibility is maintained throughout.
Quickstart
Installing the Plugin
Install via Composer:
composer require crustum/saloonLoad the plugin:
bin/cake plugin load Crustum/SaloonCreate Your First Integration
Generate a connector and request:
bin/cake bake saloon_connector GitHub GitHub
bin/cake bake saloon_request GitHub GetUserThis creates:
// src/Http/Integrations/GitHub/GitHubConnector.php
<?php
declare(strict_types=1);
namespace App\Http\Integrations\GitHub;
use Saloon\Http\Connector;
class GitHubConnector extends Connector
{
public function resolveBaseUrl(): string
{
return 'https://api.github.com';
}
protected function defaultHeaders(): array
{
return [
'Content-Type' => 'application/json',
'Accept' => 'application/vnd.github+json',
];
}
}// src/Http/Integrations/GitHub/Requests/GetUserRequest.php
<?php
declare(strict_types=1);
namespace App\Http\Integrations\GitHub\Requests;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetUserRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(protected string $username)
{
}
public function resolveEndpoint(): string
{
return '/users/' . $this->username;
}
}Make Your First Request
use App\Http\Integrations\GitHub\GitHubConnector;
use App\Http\Integrations\GitHub\Requests\GetUserRequest;
$connector = new GitHubConnector();
$response = $connector->send(new GetUserRequest('sammyjo20'));
$data = $response->json();
echo $data['name']; // "Sam Carré"Next Steps
Once you've made your first request, you're ready to explore:
Installation
Requirements
- PHP 8.2+
- CakePHP 5.x
- Guzzle (installed automatically via Saloon)
Composer Installation
composer require crustum/saloonPlugin Loading
Add to config/plugins.php:
return [
'Crustum/Saloon' => [],
];Or load via CLI:
bin/cake plugin load Crustum/SaloonAlternatively, load in your Application.php:
// In src/Application.php
public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('Crustum/Saloon');
}Configuration
Publish the default configuration:
cp vendor/crustum/saloon/config/saloon.php config/saloon.phpWhen developing from a monorepo:
cp plugins/Saloon/config/saloon.php config/saloon.phpLoad the configuration in config/bootstrap.php (after plugins are loaded):
use Cake\Core\Configure;
if (file_exists(CONFIG . 'saloon.php')) {
Configure::load('saloon', 'default');
}The default configuration:
use Crustum\Saloon\Cache\CacheDriver;
use Crustum\Saloon\RateLimit\CacheStore;
return [
'Saloon' => [
'default_sender' => \Saloon\Http\Senders\GuzzleSender::class,
'integrations_path' => ROOT . DS . 'src' . DS . 'Http' . DS . 'Integrations',
'integrations_namespace' => 'App\Http\Integrations',
'middleware' => [
'mock' => true,
'events' => true,
'speculum' => false,
'rhythm' => false,
],
'ecosystem' => [
'cache' => [
'config' => 'default',
'driver' => CacheDriver::class,
],
'rate_limit' => [
'config' => 'default',
'store' => CacheStore::class,
],
],
],
];See Configuration Reference for all available options.
Optional Ecosystem Packages
Install additional Saloon plugins as needed:
composer require saloonphp/cache-plugin # Response caching
composer require saloonphp/rate-limit-plugin # Rate limiting
composer require saloonphp/pagination-plugin # Pagination support
composer require saloonphp/xml-wrangler # Advanced XML handling
composer require crustum/speculum # Speculum monitoring
composer require crustum/rhythm # Rhythm monitoringThe Basics
Connectors
Connectors are the foundation of Saloon. They encapsulate the configuration for an API integration, including the base URL, default headers, authentication, and HTTP client configuration.
Creating a Connector
Generate a connector using Bake:
bin/cake bake saloon_connector GitHub GitHubOr create manually:
<?php
declare(strict_types=1);
namespace App\Http\Integrations\GitHub;
use Saloon\Http\Connector;
class GitHubConnector extends Connector
{
public function resolveBaseUrl(): string
{
return 'https://api.github.com';
}
protected function defaultHeaders(): array
{
return [
'Content-Type' => 'application/json',
'Accept' => 'application/vnd.github+json',
];
}
}Constructor Arguments
Pass runtime configuration to connectors:
class GitHubConnector extends Connector
{
public function __construct(
protected string $apiToken,
) {
}
public function resolveBaseUrl(): string
{
return 'https://api.github.com';
}
protected function defaultHeaders(): array
{
return [
'Content-Type' => 'application/json',
'Accept' => 'application/vnd.github+json',
'Authorization' => 'Bearer ' . $this->apiToken,
];
}
}
// Usage
$connector = new GitHubConnector($apiToken);Timeouts
Configure connection and request timeouts:
use Saloon\Traits\Plugins\HasTimeout;
class GitHubConnector extends Connector
{
use HasTimeout;
protected int $connectTimeout = 10;
protected int $requestTimeout = 30;
}HTTP Client Configuration
Customize the underlying Guzzle client:
protected function defaultConfig(): array
{
return [
'verify' => false,
'proxy' => 'http://proxy.example.com:8080',
];
}Requests
Requests define individual API endpoints. Each request specifies the HTTP method, endpoint path, headers, query parameters, and body.
Creating a Request
Generate a request using Bake:
bin/cake bake saloon_request GitHub GetRepositoryOr create manually:
<?php
declare(strict_types=1);
namespace App\Http\Integrations\GitHub\Requests;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetRepositoryRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $owner,
protected string $repo,
) {
}
public function resolveEndpoint(): string
{
return '/repos/' . $this->owner . '/' . $this->repo;
}
}Query Parameters
Add query parameters to requests:
class GetUserReposRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $username,
protected ?string $type = null,
) {
}
public function resolveEndpoint(): string
{
return '/users/' . $this->username . '/repos';
}
protected function defaultQuery(): array
{
return array_filter([
'type' => $this->type,
]);
}
}Runtime query parameters:
$request = new GetUserReposRequest('octocat');
$request->query()->add('type', 'owner');
$request->query()->merge(['sort' => 'full_name', 'direction' => 'asc']);Custom Headers
Override or add headers at the request level:
class GetRepositoryRequest extends Request
{
protected function defaultHeaders(): array
{
return [
'X-GitHub-Api-Version' => '2022-11-28',
];
}
}Runtime headers:
$request->headers()->add('X-Request-ID', uniqid());Sending Requests
Synchronous Requests
$connector = new GitHubConnector($apiToken);
$request = new GetRepositoryRequest('octocat', 'Hello-World');
$response = $connector->send($request);Or use the static factory:
$response = GitHubConnector::make($apiToken)->send(new GetRepositoryRequest('octocat', 'Hello-World'));Asynchronous Requests
$promise = $connector->sendAsync($request);
$promise
->then(function (Response $response) {
// Handle success
})
->otherwise(function (RequestException $exception) {
// Handle error
});
$promise->wait(); // Block until completeResponses
Saloon provides a rich Response object with methods for accessing status, headers, and body data.
Status Checks
$response->status(); // 200
$response->ok(); // true if 2xx
$response->successful(); // true if 2xx
$response->failed(); // true if 4xx or 5xx
$response->clientError(); // true if 4xx
$response->serverError(); // true if 5xx
$response->redirect(); // true if 3xxBody Access
$response->body(); // Raw string
$response->json(); // Decoded JSON array
$response->array(); // Alias for json()
$response->object(); // JSON as stdClass
$response->collect(); // Laravel Collection (requires illuminate/collections)
$response->stream(); // PSR-7 StreamInterfaceHeader Access
$response->headers(); // All headers
$response->header('Content-Type');Solo Requests
For one-off API calls that don't need a connector, use SoloRequest:
<?php
declare(strict_types=1);
namespace App\Http\Integrations\Utilities;
use Saloon\Enums\Method;
use Saloon\Http\SoloRequest;
class IpAddressRequest extends SoloRequest
{
protected Method $method = Method::GET;
public function resolveEndpoint(): string
{
return 'https://api.ipify.org?format=json';
}
}
// Usage
$response = (new IpAddressRequest())->send();
$ip = $response->json()['ip'];WARNING
Security Warning: Never accept user-generated URLs in solo requests. This can lead to Server-Side Request Forgery (SSRF) vulnerabilities.
Authentication
Saloon provides built-in authenticators for common authentication patterns.
Token Authentication
Bearer token authentication:
use Saloon\Http\Auth\TokenAuthenticator;
class GitHubConnector extends Connector
{
public function __construct(
protected string $token,
) {
}
protected function defaultAuth(): ?Authenticator
{
return new TokenAuthenticator($this->token);
}
}Runtime authentication:
$connector->authenticate(new TokenAuthenticator($token));Basic Authentication
HTTP Basic authentication:
use Saloon\Http\Auth\BasicAuthenticator;
protected function defaultAuth(): ?Authenticator
{
return new BasicAuthenticator($username, $password);
}Query Parameter Authentication
API key in query string:
use Saloon\Http\Auth\QueryAuthenticator;
protected function defaultAuth(): ?Authenticator
{
return new QueryAuthenticator('api_key', $this->apiKey);
}Custom Authenticators
Create custom authentication logic:
<?php
declare(strict_types=1);
namespace App\Http\Integrations\GitHub\Auth;
use Saloon\Contracts\Authenticator;
use Saloon\Http\PendingRequest;
class CustomAuthenticator implements Authenticator
{
public function __construct(
protected string $customToken,
) {
}
public function set(PendingRequest $pendingRequest): void
{
$pendingRequest->headers()->add('X-Custom-Auth', $this->customToken);
}
}Generate authenticator scaffolding:
bin/cake bake saloon_authenticator GitHub CustomOAuth2 Authentication
Saloon supports OAuth2 Authorization Code Grant:
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\OAuth2\AuthorizationCodeGrant;
class SpotifyConnector extends Connector
{
use AuthorizationCodeGrant;
public function __construct(
protected string $clientId,
protected string $clientSecret,
protected string $redirectUri,
) {
}
protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make()
->setClientId($this->clientId)
->setClientSecret($this->clientSecret)
->setRedirectUri($this->redirectUri)
->setDefaultScopes(['user-read-email'])
->setAuthorizeEndpoint('https://accounts.spotify.com/authorize')
->setTokenEndpoint('https://accounts.spotify.com/api/token')
->setUserEndpoint('https://api.spotify.com/v1/me');
}
}Scaffold an OAuth2 connector (with the AuthorizationCodeGrant trait and defaultOauthConfig()) using the bake generator:
bin/cake bake saloon_connector Spotify Spotify --oauthOAuth2 flow:
$connector = new SpotifyConnector($clientId, $clientSecret, $redirectUri);
// Step 1: Redirect user to authorization URL
$authUrl = $connector->getAuthorizationUrl(['user-read-email'], $state);
header('Location: ' . $authUrl);
// Step 2: Handle callback and exchange code for token
$authenticator = $connector->getAccessToken($_GET['code'], $_GET['state']);
// Step 3: Store the authenticator (contains access + refresh tokens)
// $authenticator->getAccessToken()
// $authenticator->getRefreshToken()
// $authenticator->getExpiresAt()
// Step 4: Use authenticated connector
$connector->authenticate($authenticator);
$response = $connector->send(new GetUserRequest());
// Step 5: Refresh when expired
if ($authenticator->hasExpired()) {
$newAuthenticator = $connector->refreshAccessToken($authenticator);
$connector->authenticate($newAuthenticator);
}Request Bodies
JSON Body
Send JSON data:
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Traits\Body\HasJsonBody;
class CreateRepositoryRequest extends Request implements HasBody
{
use HasJsonBody;
protected Method $method = Method::POST;
public function __construct(
protected string $name,
protected string $description = '',
) {
}
public function resolveEndpoint(): string
{
return '/user/repos';
}
protected function defaultBody(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'private' => false,
];
}
}Runtime body manipulation:
$request = new CreateRepositoryRequest('Hello-World', 'My first repository');
$request->body()->add('private', true);
$request->body()->merge(['has_issues' => true]);
$request->body()->remove('description');
$request->body()->all(); // Get all body dataMultipart Form Body
Send multipart form data with file uploads:
use Saloon\Contracts\Body\HasBody;
use Saloon\Data\MultipartValue;
use Saloon\Traits\Body\HasMultipartBody;
class UploadAvatarRequest extends Request implements HasBody
{
use HasMultipartBody;
protected Method $method = Method::POST;
public function __construct(
protected string $filePath,
protected string $username,
) {
}
protected function defaultBody(): array
{
return [
new MultipartValue(
name: 'avatar',
value: fopen($this->filePath, 'r'),
filename: basename($this->filePath),
),
new MultipartValue('username', $this->username),
];
}
}Attach files at runtime:
$request->body()->attach('avatar', $fileResource, 'avatar.jpg', ['Content-Type' => 'image/jpeg']);
$request->body()->add('field', 'value');XML Body
Send XML data:
use Saloon\Traits\Body\HasXmlBody;
class SendXmlRequest extends Request implements HasBody
{
use HasXmlBody;
protected Method $method = Method::POST;
protected function defaultBody(): string
{
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<request>
<name>John Doe</name>
<email>john@example.com</email>
</request>
XML;
}
}TIP
For advanced XML handling, consider using the XML Wrangler plugin.
URL Encoded Form Body
Send form-urlencoded data:
use Saloon\Traits\Body\HasFormBody;
class LoginRequest extends Request implements HasBody
{
use HasFormBody;
protected Method $method = Method::POST;
public function __construct(
protected string $username,
protected string $password,
) {
}
protected function defaultBody(): array
{
return [
'username' => $this->username,
'password' => $this->password,
];
}
}String/Plain Text Body
Send plain text:
use Saloon\Traits\Body\HasStringBody;
class SendTextRequest extends Request implements HasBody
{
use HasStringBody;
protected Method $method = Method::POST;
public function __construct(
protected string $content,
) {
}
protected function defaultHeaders(): array
{
return [
'Content-Type' => 'text/plain',
];
}
protected function defaultBody(): string
{
return $this->content;
}
}Stream Body
Send stream resources:
use Saloon\Traits\Body\HasStreamBody;
class UploadFileRequest extends Request implements HasBody
{
use HasStreamBody;
protected Method $method = Method::POST;
public function __construct(
protected $fileResource,
) {
}
protected function defaultHeaders(): array
{
return [
'Content-Type' => 'application/octet-stream',
];
}
protected function defaultBody()
{
return $this->fileResource;
}
}Handling Responses
Response Data
Access response data in various formats:
// JSON
$data = $response->json();
$name = $response->json('user.name'); // Dot notation
// Object
$object = $response->object();
echo $object->user->name;
// XML
$xml = $response->xmlReader(); // Requires saloonphp/xml-wrangler
$values = $xml->values();
// HTML/DOM
$crawler = $response->dom(); // Requires symfony/dom-crawler
$title = $crawler->filter('title')->text();
// Collection
$collection = $response->collect(); // Requires illuminate/collections
$filtered = $collection->filter(fn($item) => $item['active']);Response Status
$response->status(); // 200
$response->ok(); // true
$response->successful(); // true
$response->redirect(); // false
$response->failed(); // false
$response->clientError(); // false
$response->serverError(); // falseResponse Headers
$headers = $response->headers();
$contentType = $response->header('Content-Type');Saving Response to File
$response->saveBodyToFile('/path/to/file.pdf');Error Handling
Exception Hierarchy
Saloon uses a well-structured exception hierarchy:
SaloonException
├── FatalRequestException (connection errors, timeouts)
└── RequestException (HTTP errors)
├── ServerException (5xx errors)
│ ├── InternalServerErrorException (500)
│ ├── ServiceUnavailableException (503)
│ └── GatewayTimeoutException (504)
└── ClientException (4xx errors)
├── UnauthorizedException (401)
├── ForbiddenException (403)
├── NotFoundException (404)
├── MethodNotAllowedException (405)
└── TooManyRequestsException (429)Throwing Exceptions
Per-response throwing:
try {
$response = $connector->send($request);
$response->throw(); // Throw if 4xx or 5xx
$data = $response->json();
} catch (RequestException $exception) {
// Handle error
$errorResponse = $exception->getResponse();
$errorBody = $errorResponse->json();
}Always throw on errors:
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
class GitHubConnector extends Connector
{
use AlwaysThrowOnErrors;
}Now all requests automatically throw on 4xx/5xx responses.
Custom Error Detection
Some APIs return 200 with error information in the body:
public function hasRequestFailed(Response $response): bool
{
$data = $response->json();
return isset($data['error']) && $data['error'] === true;
}Custom exceptions:
protected function getRequestException(
Response $response,
?\Exception $senderException
): ?RequestException {
$data = $response->json();
if (isset($data['error_code']) && $data['error_code'] === 'RATE_LIMITED') {
return new CustomRateLimitException($response, $senderException);
}
return parent::getRequestException($response, $senderException);
}Advanced Features
Data Transfer Objects
Convert responses to strongly-typed DTOs:
<?php
declare(strict_types=1);
namespace App\Http\Integrations\GitHub\Dto;
class User
{
public function __construct(
public int $id,
public string $login,
public string $name,
public ?string $email = null,
) {
}
}Implement DTO creation in your request:
use App\Http\Integrations\GitHub\Dto\User;
class GetUserRequest extends Request
{
// ...
public function createDtoFromResponse(Response $response): mixed
{
$data = $response->json();
return new User(
id: $data['id'],
login: $data['login'],
name: $data['name'],
email: $data['email'] ?? null,
);
}
}Retrieve the DTO:
$response = $connector->send(new GetUserRequest('sammyjo20'));
$user = $response->dto(); // Returns User instance
// Or throw on failure
$user = $response->dtoOrFail();Access original response from DTO:
use Saloon\Contracts\DataObjects\WithResponse;
use Saloon\Traits\Responses\HasResponse;
class User implements WithResponse
{
use HasResponse;
// ...
}
$user = $response->dto();
$originalResponse = $user->getResponse();Retrying Requests
Configure automatic retries:
class GitHubConnector extends Connector
{
public int $tries = 3;
public int $retryInterval = 500; // milliseconds
public bool $useExponentialBackoff = true;
public bool $throwOnMaxTries = true;
}Custom retry logic:
protected function handleRetry(
FatalRequestException|RequestException $exception,
Request $request
): bool {
// Refresh auth token on 401
if ($exception->getResponse()?->status() === 401) {
$this->authenticate($this->refreshToken());
return true; // Retry the request
}
// Don't retry on 404
if ($exception->getResponse()?->status() === 404) {
return false;
}
return true; // Use default retry behavior
}NOTE
Retry logic only works with synchronous requests, not async or pools.
Request Delays
Add delays between requests:
// Connector-level default
protected function defaultDelay(): int
{
return 500; // milliseconds
}
// Runtime delay
$connector->delay()->set(1000);Request-level delays override connector delays.
Concurrency & Pools
Send multiple requests concurrently:
$requests = [
new GetUserRequest('octocat'),
new GetUserRequest('skie'),
];
$pool = $connector->pool(
requests: $requests,
concurrency: 5,
responseHandler: function (Response $response, $key) {
// Handle each successful response
echo "User {$key}: " . $response->json('login') . "\n";
},
exceptionHandler: function (\Exception $exception, $key) {
// Handle each failed request
echo "User {$key} failed: " . $exception->getMessage() . "\n";
}
);
$promise = $pool->send();
$promise->wait();Named requests:
$pool = $connector->pool([
'octocat' => new GetUserRequest('octocat'),
'skie' => new GetUserRequest('skie'),
]);Generators for memory efficiency:
$pool = $connector->pool(function () {
foreach (['octocat', 'skie'] as $username) {
yield new GetUserRequest($username);
}
});Middleware
Boot method (executes before every request):
public function boot(PendingRequest $pendingRequest): void
{
$pendingRequest->headers()->add('X-App-Version', '1.0.0');
}Request middleware:
$connector->middleware()->onRequest(function (PendingRequest $pendingRequest) {
ray('Sending request:', $pendingRequest->getUri());
// Optionally modify and return
return $pendingRequest;
// Or return early response
// return new FakeResponse(['mocked' => true]);
});Response middleware:
$connector->middleware()->onResponse(function (Response $response) {
ray('Received response:', $response->status());
});Custom middleware classes:
use Saloon\Contracts\RequestMiddleware;
class LoggingMiddleware implements RequestMiddleware
{
public function __invoke(PendingRequest $pendingRequest): void
{
Log::write('debug', 'Saloon request: ' . $pendingRequest->getUri());
}
}
$connector->middleware()->onRequest(new LoggingMiddleware(), 'logging');Debugging
Requires symfony/var-dumper (included in CakePHP by default).
// Debug request + response
$connector->debug()->send($request);
// Debug request only
$connector->debugRequest()->send($request);
// Debug response only
$connector->debugResponse()->send($request);
// Die after debugging
$connector->debug(die: true)->send($request);
// Custom debugging logic
$connector->debug(function ($pendingRequest, $psrRequest) {
ray($psrRequest);
})->send($request);Building SDKs
Connector as SDK Root
The connector serves as the SDK entry point:
class SpotifyConnector extends Connector
{
public function __construct(
protected string $apiToken,
) {
}
public function resolveBaseUrl(): string
{
return 'https://api.spotify.com/v1';
}
protected function defaultAuth(): ?Authenticator
{
return new TokenAuthenticator($this->apiToken);
}
}
// Usage
$sdk = new SpotifyConnector($apiToken);
$response = $sdk->send(new GetPlaylistRequest($id));Resource Pattern
Organize endpoints into resource classes:
class SpotifyConnector extends Connector
{
// ...
public function playlists(): PlaylistResource
{
return new PlaylistResource($this);
}
public function tracks(): TrackResource
{
return new TrackResource($this);
}
public function users(): UserResource
{
return new UserResource($this);
}
}Base resource class:
<?php
declare(strict_types=1);
namespace App\Http\Integrations\Spotify\Resources;
use Saloon\Http\Connector;
use Saloon\Http\Response;
abstract class Resource
{
public function __construct(
protected Connector $connector,
) {
}
protected function send($request): Response
{
return $this->connector->send($request);
}
}Playlist resource:
class PlaylistResource extends Resource
{
public function get(string $id): Response
{
return $this->send(new GetPlaylistRequest($id));
}
public function create(array $data): Response
{
return $this->send(new CreatePlaylistRequest($data));
}
public function update(string $id, array $data): Response
{
return $this->send(new UpdatePlaylistRequest($id, $data));
}
public function delete(string $id): Response
{
return $this->send(new DeletePlaylistRequest($id));
}
}Usage:
$sdk = new SpotifyConnector($apiToken);
// Clean, expressive API
$playlist = $sdk->playlists()->get('37i9dQZF1DXcBWIGoYBM5M');
$tracks = $sdk->tracks()->search('Never Gonna Give You Up');
$user = $sdk->users()->me();Method-Based Approach
Simple method wrappers on the connector:
class SpotifyConnector extends Connector
{
// ...
public function getPlaylist(string $id): Response
{
return $this->send(new GetPlaylistRequest($id));
}
public function createPlaylist(array $data): Response
{
return $this->send(new CreatePlaylistRequest($data));
}
}
// Usage
$sdk = new SpotifyConnector($apiToken);
$playlist = $sdk->getPlaylist($id);Pagination
Install the pagination plugin:
composer require saloonphp/pagination-pluginPaged Pagination
For APIs using page numbers:
use Saloon\Http\Request;
use Saloon\Http\Response;
use Saloon\PaginationPlugin\Contracts\HasPagination;
use Saloon\PaginationPlugin\PagedPaginator;
class GitHubConnector extends Connector implements HasPagination
{
public function paginate(Request $request): PagedPaginator
{
return new class($this, $request) extends PagedPaginator
{
protected ?int $perPageLimit = 100;
protected function isLastPage(Response $response): bool
{
// GitHub returns a JSON array of items for list endpoints
return empty($response->json());
}
protected function getPageItems(Response $response, Request $request): array
{
return $response->json();
}
protected function applyPagination(Request $request): Request
{
$request->query()->set('page', $this->currentPage);
$request->query()->set('per_page', $this->perPageLimit);
return $request;
}
};
}
}Usage:
$connector = new GitHubConnector($apiToken);
// Iterate over pages
foreach ($connector->paginate(new GetUserReposRequest('octocat')) as $response) {
$repos = $response->json();
// Process repositories...
}
// Iterate over items
foreach ($connector->paginate(new GetUserReposRequest('octocat'))->items() as $repo) {
// Process individual repository...
}
// Collect all items (LazyCollection)
$allRepos = $connector->paginate(new GetUserReposRequest('octocat'))->collect();Offset Pagination
For APIs using limit/offset:
use Saloon\PaginationPlugin\OffsetPaginator;
return new class($this, $request) extends OffsetPaginator
{
protected ?int $perPageLimit = 100;
protected function isLastPage(Response $response): bool
{
return empty($response->json('data'));
}
protected function getPageItems(Response $response, Request $request): array
{
return $response->json('data');
}
};Cursor Pagination
For APIs using cursor tokens:
use Saloon\PaginationPlugin\CursorPaginator;
return new class($this, $request) extends CursorPaginator
{
protected ?int $perPageLimit = 100;
protected function getNextCursor(Response $response): int|string
{
return $response->json('next_cursor');
}
protected function isLastPage(Response $response): bool
{
return $response->json('next_cursor') === null;
}
protected function getPageItems(Response $response, Request $request): array
{
return $response->json('data');
}
protected function applyPagination(Request $request): Request
{
if (isset($this->currentCursor)) {
$request->query()->set('cursor', $this->currentCursor);
}
return $request;
}
};Custom Pagination
For unique pagination schemes:
use Saloon\PaginationPlugin\Paginator;
return new class($this, $request) extends Paginator
{
protected function isLastPage(Response $response): bool
{
// Your logic
}
protected function getPageItems(Response $response, Request $request): array
{
// Your logic
}
protected function applyPagination(Request $request): Request
{
// Your logic
return $request;
}
};Caching
Install the cache plugin:
composer require saloonphp/cache-pluginSetup Caching
use Saloon\CachePlugin\Contracts\Cacheable;
use Saloon\CachePlugin\Contracts\Driver;
use Saloon\CachePlugin\Traits\HasCaching;
class GitHubConnector extends Connector implements Cacheable
{
use HasCaching;
protected function resolveCacheDriver(): Driver
{
// Return cache driver (see below)
}
protected function cacheExpiryInSeconds(): int
{
return 3600; // 1 hour
}
}CakePHP Cache Driver
Use the provided CakePHP cache bridge:
use Crustum\Saloon\Config\SaloonConfig;
class GitHubConnector extends Connector implements Cacheable
{
use HasCaching;
protected function resolveCacheDriver(): Driver
{
return SaloonConfig::cacheDriver();
}
}Cache Configuration
Configure the CakePHP cache config and driver in config/saloon.php:
use Crustum\Saloon\Cache\CacheDriver;
return [
'Saloon' => [
'ecosystem' => [
'cache' => [
'config' => 'default',
'driver' => CacheDriver::class,
],
],
],
];The default CacheDriver uses Cache::pool($config) (PSR-6) for TTL-aware storage.
Custom Cache Driver
SaloonConfig::cacheDriver() returns a Saloon\CachePlugin\Contracts\Driver and resolves ecosystem.cache.driver, so you are not limited to the Cake cache bridge. It accepts:
- a
Driverinstance, - a factory callable returning a
Driver, - a service id / class resolvable from the application container.
For example, to store cached responses on a Flysystem filesystem, register the driver in your application's services() and point the config at it:
// src/Application.php
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use Saloon\CachePlugin\Drivers\FlysystemDriver;
public function services(ContainerInterface $container): void
{
$container->addShared('saloon.flysystem.cache', fn(): FlysystemDriver => new FlysystemDriver(
new Filesystem(new LocalFilesystemAdapter(ROOT . DS . 'tmp' . DS . 'saloon-cache')),
));
}// config/saloon.php
return [
'Saloon' => [
'ecosystem' => [
'cache' => [
'driver' => 'saloon.flysystem.cache',
],
],
],
];An inline factory works too, which is handy for tests or single-file setups:
'ecosystem' => [
'cache' => [
'driver' => fn(): Driver => new FlysystemDriver($filesystem),
],
],NOTE
The plugin does not register the bridge classes in the container itself, so the app's services() bindings always win. Configure ecosystem.cache.driver (or pass a Driver instance) to swap implementations.
Invalidating Cache
// Check if response was cached
if ($response->isCached()) {
// ...
}
// Invalidate cache for a specific request
$connector->invalidateCache(new GetRepositoryRequest('octocat', 'Hello-World'));
// Disable caching per-request
$request->disableCaching();Custom cache keys:
protected function cacheKey(Request $request): ?string
{
return 'custom-key-' . $request->resolveEndpoint();
}Customize cacheable methods (default: GET, OPTIONS):
protected function getCacheableMethods(): array
{
return [Method::GET, Method::HEAD];
}Rate Limiting
Install the rate limit plugin:
composer require saloonphp/rate-limit-pluginSetup Rate Limiting
use Saloon\RateLimitPlugin\Contracts\RateLimitStore;
use Saloon\RateLimitPlugin\Limit;
use Saloon\RateLimitPlugin\Traits\HasRateLimits;
class GitHubConnector extends Connector
{
use HasRateLimits;
protected function resolveLimits(): array
{
return [
Limit::allow(60)->everyMinute(),
Limit::allow(1000)->everyDay(),
];
}
protected function resolveRateLimitStore(): RateLimitStore
{
// Return rate limit store (see below)
}
}CakePHP Rate Limit Store
Use the provided CakePHP cache bridge:
use Crustum\Saloon\Config\SaloonConfig;
protected function resolveRateLimitStore(): RateLimitStore
{
return SaloonConfig::rateLimitStore();
}Configure the CakePHP cache config and store in config/saloon.php:
use Crustum\Saloon\RateLimit\CacheStore;
return [
'Saloon' => [
'ecosystem' => [
'rate_limit' => [
'config' => 'default',
'store' => CacheStore::class,
],
],
],
];Like cacheDriver(), SaloonConfig::rateLimitStore() returns a Saloon\RateLimitPlugin\Contracts\RateLimitStore and resolves ecosystem.rate_limit.store from a RateLimitStore instance, a factory callable, or an application container service id/class — so any custom store can be swapped in without touching the plugin.
Rate Limit Configuration
Available limit durations:
Limit::allow(100)->everySeconds(5)
Limit::allow(60)->everyMinute()
Limit::allow(60)->everyFiveMinutes()
Limit::allow(1000)->everyHour()
Limit::allow(5000)->everyDay()
Limit::allow(100000)->everyMonth()
// Until specific time
Limit::allow(60)->untilEndOfMinute()
Limit::allow(1000)->untilEndOfHour()
Limit::allow(10000)->untilMidnightTonight()
Limit::allow(1000)->everyDayUntil('8pm')Leaky bucket algorithm:
use Saloon\RateLimitPlugin\Bucket;
protected function resolveLimits(): array
{
return [
Bucket::capacity(60)->leak(1)->everySeconds(1)->sleep(),
];
}Handling Rate Limits
Throw exception (default):
use Saloon\RateLimitPlugin\Exceptions\RateLimitReachedException;
try {
$response = $connector->send($request);
} catch (RateLimitReachedException $exception) {
// Limit reached
$exception->getLimit();
$exception->getRetryAfter();
}Sleep until available:
protected function resolveLimits(): array
{
return [
Limit::allow(60)->everyMinute()->sleep(),
];
}Per-user limits:
protected function resolveLimits(): array
{
return [
Limit::allow(60)
->everyMinute()
->name('user-' . $this->userId),
];
}Threshold warnings:
Limit::allow(60, threshold: 0.8)->everyMinute()Auto-detect 429 responses:
The plugin automatically detects 429 Too Many Requests responses and parses the Retry-After header.
CakePHP Integration
Events
When middleware.events is enabled (default), every $connector->send() dispatches CakePHP events.
Event Classes
| Event | When | Properties |
|---|---|---|
SendingSaloonRequest | Before HTTP request is sent | $pendingRequest |
SentSaloonRequest | After response is received | $pendingRequest, $response |
Listening to Events
use Cake\Event\EventInterface;
use Cake\Event\EventListenerInterface;
use Crustum\Saloon\Event\SendingSaloonRequest;
use Crustum\Saloon\Event\SentSaloonRequest;
class SaloonRequestLogger implements EventListenerInterface
{
public function implementedEvents(): array
{
return [
SendingSaloonRequest::class => 'onSending',
SentSaloonRequest::class => 'onSent',
];
}
public function onSending(SendingSaloonRequest $event): void
{
$pendingRequest = $event->getPendingRequest();
Log::write('debug', 'Sending request to: ' . $pendingRequest->getUri());
}
public function onSent(SentSaloonRequest $event): void
{
$response = $event->getResponse();
Log::write('debug', 'Received response: ' . $response->status());
}
}Register the listener in config/bootstrap.php:
use Cake\Event\EventManager;
EventManager::instance()->on(new SaloonRequestLogger());Application Monitoring
Saloon ships two optional global middlewares that feed CakePHP's application monitoring plugins. Each is opt-in, silently disables itself when its plugin is not installed, and reads the same configuration the plugin's native watcher/recorder uses — so disabling the watcher or recorder in the monitoring plugin also disables Saloon recording, with no extra wiring.
| Middleware | Plugin | Records |
|---|---|---|
middleware.speculum | crustum/speculum | Every Saloon request/response as an http_client entry |
middleware.rhythm | crustum/rhythm | Slow Saloon requests as a slow_outgoing_request metric |
Install the plugin you want to use, then enable its toggle:
composer require crustum/speculum
composer require crustum/rhythm// config/saloon.php
return [
'Saloon' => [
'middleware' => [
'speculum' => true,
'rhythm' => true,
],
],
];Speculum
When middleware.speculum is enabled and crustum/speculum is installed, every $connector->send() is recorded to Speculum as an http_client entry — method, URI, headers, payload, response status/headers/body, and duration. Entries appear in the HTTP Clients panel.
Recording respects the native HttpClientWatcher configuration:
- Speculum must be recording and the
HttpClientWatchermust be enabled. - Hosts matched by the watcher's
ignore_hostsoption are skipped. Speculum::$hiddenRequestHeaders,$hiddenRequestParameters, and$hiddenResponseParametersredaction is applied before the entry is stored.
return [
'Speculum' => [
'watchers' => [
\Crustum\Speculum\Watcher\HttpClientWatcher::class => [
'enabled' => true,
'ignore_hosts' => ['internal.example.com'],
],
],
],
];Rhythm
When middleware.rhythm is enabled and crustum/rhythm is installed, slow Saloon requests are recorded using the same metric the native OutgoingRequestRecorder writes: type slow_outgoing_request, key [method, groupedUri], duration value, with max and count aggregations. Measurements appear in the Slow Outgoing Requests widget.
Recording is driven entirely by the native recorder configuration:
return [
'Rhythm' => [
'recorders' => [
'slow_outgoing_requests' => [
'enabled' => true,
'threshold' => [
'default' => 1000,
'/^https?:\/\/api\./' => 500,
],
'sample_rate' => 1.0,
'ignore' => ['/^https?:\/\/localhost/'],
'groups' => [
'#^(https?://api\.[^/]+)/([^/]+)/(\d+)#' => '\1/\2/{id}',
],
],
],
],
];Because the Saloon middleware reads the same configuration, disabling the recorder (enabled => false) or raising its threshold also stops Saloon from recording — there is no separate Saloon-side option block to keep in sync.
CLI Generators
Generate Saloon integration classes using CakePHP Bake:
Available Commands
| Command | Arguments | Output |
|---|---|---|
bake saloon_connector | {integration} {name} [--oauth] | {Integration}Connector.php |
bake saloon_request | {integration} {name} | Requests/{Name}Request.php |
bake saloon_response | {integration} {name} | Responses/{Name}Response.php |
bake saloon_plugin | {integration} {name} | Plugins/{Name}Plugin.php |
bake saloon_authenticator | {integration} {name} | Auth/{Name}Authenticator.php |
saloon list | — | Lists integrations and class counts |
Examples
# Create a connector
bin/cake bake saloon_connector JsonPlaceholder JsonPlaceholder
# Create a connector with OAuth2 Authorization Code Grant boilerplate
bin/cake bake saloon_connector Spotify Spotify --oauth
# Create a request
bin/cake bake saloon_request JsonPlaceholder GetPost --method GET
# Create a custom response
bin/cake bake saloon_response JsonPlaceholder Post
# Create a plugin/trait
bin/cake bake saloon_plugin JsonPlaceholder Logging
# Create an authenticator
bin/cake bake saloon_authenticator JsonPlaceholder Custom
# List all integrations
bin/cake saloon listCustom Templates
Override bake templates by copying them to your application:
cp -r plugins/Saloon/templates/bake/Saloon/ templates/bake/Saloon/Or use a custom theme:
bin/cake bake saloon_connector GitHub GitHub --theme MyThemeConfiguration Reference
All configuration is nested under the Saloon key in config/saloon.php:
| Key | Default | Description |
|---|---|---|
default_sender | GuzzleSender::class | Global Saloon sender class (must implement Saloon\Contracts\Sender, otherwise Guzzle is used) |
integrations_path | src/Http/Integrations | Directory for generated integrations |
integrations_namespace | App\Http\Integrations | PHP namespace for generated classes |
middleware.mock | true | Attach global mock client when faking |
middleware.events | true | Dispatch CakePHP events on every send |
middleware.speculum | false | Record Saloon requests in Speculum (requires crustum/speculum) |
middleware.rhythm | false | Record slow Saloon requests in Rhythm (requires crustum/rhythm) |
ecosystem.cache.config | default | CakePHP cache config used by the cache plugin bridge |
ecosystem.cache.driver | CacheDriver::class | Cache plugin driver instance, factory, or container service |
ecosystem.rate_limit.config | default | CakePHP cache config used by the rate limit store bridge |
ecosystem.rate_limit.store | CacheStore::class | Rate limit store instance, factory, or container service |
See Application Monitoring for the Speculum and Rhythm middlewares.
The deprecated response recording middleware (RecordResponse) is always registered and records only while Saloon::record() is active; call Saloon::stopRecording() to disable it.
Example configuration:
use Crustum\Saloon\Cache\CacheDriver;
use Crustum\Saloon\RateLimit\CacheStore;
return [
'Saloon' => [
'default_sender' => \Saloon\Http\Senders\GuzzleSender::class,
'integrations_path' => ROOT . DS . 'src' . DS . 'Http' . DS . 'Integrations',
'integrations_namespace' => 'App\Http\Integrations',
'middleware' => [
'mock' => true,
'events' => true,
'speculum' => false,
'rhythm' => false,
],
'ecosystem' => [
'cache' => [
'config' => 'default',
'driver' => CacheDriver::class,
],
'rate_limit' => [
'config' => 'default',
'store' => CacheStore::class,
],
],
],
];Testing
Mocking Responses
Use the global Saloon facade to mock HTTP responses:
use Crustum\Saloon\Saloon;
use Saloon\Http\Faking\MockResponse;
class UserTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Saloon::resetMockState();
}
public function testGetUser(): void
{
Saloon::fake([
GetUserRequest::class => MockResponse::make([
'id' => 583231,
'login' => 'octocat',
'name' => 'The Octocat',
], 200),
]);
$connector = new GitHubConnector($apiToken);
$response = $connector->send(new GetUserRequest('octocat'));
$this->assertTrue($response->ok());
$this->assertEquals('The Octocat', $response->json('name'));
}
}Always reset mock state in setUp() to avoid test pollution:
protected function setUp(): void
{
parent::setUp();
Saloon::resetMockState();
}Fixtures
Record real API responses as fixtures for testing:
// First run: Makes real request and saves response
Saloon::fake([
GetUserRequest::class => MockResponse::fixture('github/get-user'),
]);
// Subsequent runs: Uses saved fixtureFixtures are stored in tests/Fixtures/Saloon/ by default.
Customize fixture path:
use Saloon\Http\Faking\MockConfig;
MockConfig::setFixturePath('/custom/path');Sensitive data redaction:
Create custom fixture classes:
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Saloon;
use Saloon\Http\Faking\Fixture;
class GitHubFixture extends Fixture
{
protected function defineSensitiveHeaders(): array
{
return ['Authorization', 'X-API-Key'];
}
protected function defineSensitiveJsonParameters(): array
{
return ['api_token', 'password'];
}
protected function defineSensitiveRegexPatterns(): array
{
return [
'/Bearer\s+\w+/i' => 'Bearer [REDACTED]',
];
}
}Use the custom fixture:
MockResponse::fixture('github/user', GitHubFixture::class);Assertions
Assert that specific requests were sent:
use Crustum\Saloon\Saloon;
Saloon::fake([
GetUserRequest::class => MockResponse::make([
'id' => 583231,
'login' => 'octocat',
], 200),
GetRepositoryRequest::class => MockResponse::make([
'id' => 1296269,
'full_name' => 'octocat/Hello-World',
], 200),
]);
$connector = new GitHubConnector($apiToken);
$connector->send(new GetUserRequest('octocat'));
$connector->send(new GetRepositoryRequest('octocat', 'Hello-World'));
// Assert specific request was sent
Saloon::assertSent(GetUserRequest::class);
// Assert with callback for custom matching
Saloon::assertSent(function (GetUserRequest $request) {
return $request->username === 'octocat';
});
// Assert request was sent N times
Saloon::assertSent(GetUserRequest::class, 1);
// Assert total requests sent
Saloon::assertSentCount(2);
// Assert request was NOT sent
Saloon::assertNotSent(CreateRepositoryRequest::class);
// Assert no requests sent
Saloon::assertNothingSent();URL pattern matching:
Saloon::fake([
'api.github.com/users/*' => MockResponse::make(['login' => 'octocat'], 200),
'api.github.com/repos/*' => MockResponse::make(['full_name' => 'octocat/Hello-World'], 200),
'*' => MockResponse::make(['message' => 'Not Found'], 404),
]);Saloon Trait
The CakePHP-idiomatic way to mock Saloon requests is the SaloonTrait, which follows the same pattern as CakePHP's Cake\Http\TestSuite\HttpClientTrait. Add it to a test case to get mock helpers, assertion shorthands, and automatic cleanup after each test:
use Cake\TestSuite\TestCase;
use Crustum\Saloon\TestSuite\SaloonTrait;
use Saloon\Http\Faking\MockResponse;
class GitHubTest extends TestCase
{
use SaloonTrait;
public function testGetUser(): void
{
$this->fakeSaloon([
GetUserRequest::class => MockResponse::make(['login' => 'octocat'], 200),
]);
(new GitHubConnector($this->token))->send(new GetUserRequest('octocat'));
$this->assertSaloonSent(GetUserRequest::class);
$this->assertSaloonSentCount(1);
}
}| Method | Description |
|---|---|
fakeSaloon(array $responses) | Register mock responses on the global mock client |
assertSaloonSent($value) | Assert a request was sent (class or callback matcher) |
assertSaloonNotSent($value) | Assert a request was not sent |
assertSaloonSentJson($class, $data) | Assert a request was sent with the given JSON payload |
assertSaloonNothingSent() | Assert no requests were sent |
assertSaloonSentCount($count) | Assert the number of requests sent |
The trait clears mocked responses, deprecated recorded responses, and APM timing state after every test via a PHPUnit #[After] hook, so there is no manual setUp() cleanup. The static Crustum\Saloon\Saloon helpers remain available for parity and for tests that prefer explicit reset calls.
NOTE
The Laravel plugin's deprecated Saloon\Laravel\Http\Faking\MockClient wrapper is intentionally not ported. Use SaloonTrait (or the Saloon static helpers) instead.
Event Testing
Test that CakePHP events are dispatched:
use Crustum\Saloon\Event\SendingSaloonRequest;
use Crustum\Saloon\Event\SentSaloonRequest;
use Crustum\Saloon\Test\SaloonEventFake;
class EventTest extends TestCase
{
public function testEventsDispatched(): void
{
SaloonEventFake::fake();
Saloon::fake([
GetUserRequest::class => MockResponse::make(['id' => 1], 200),
]);
$connector = new TestConnector();
$connector->send(new GetUserRequest());
SaloonEventFake::assertDispatched(SendingSaloonRequest::class);
SaloonEventFake::assertDispatched(SentSaloonRequest::class);
SaloonEventFake::assertDispatched(
SendingSaloonRequest::class,
function (SendingSaloonRequest $event) {
return str_contains($event->getPendingRequest()->getUri(), '/users');
}
);
}
}Preventing Stray Requests
Prevent accidental real HTTP requests in tests:
use Saloon\Config\Config;
class TestCase extends \Cake\TestSuite\TestCase
{
protected function setUp(): void
{
parent::setUp();
// Throw exception if real requests are attempted
Config::preventStrayRequests();
Saloon::resetMockState();
}
}Prevent fixture recording in CI:
use Saloon\Http\Faking\MockConfig;
MockConfig::throwOnMissingFixtures();Official Saloon Documentation
For comprehensive Saloon documentation, visit https://docs.saloon.dev/
Topics covered in the official docs:
- The Basics: Installation, Connectors, Requests, Authentication, Request Bodies, Sending Requests, Responses, Error Handling, Debugging, Testing
- Digging Deeper: DTOs, Building SDKs, Solo Requests, Retrying, Delays, Concurrency, OAuth2, Middleware, PSR Support
- Installable Plugins: Pagination, Laravel Plugin, Caching, Rate Limiting, XML Wrangler, Auto SDK Generator
- Resources: Official Book, How-to Guides, Tutorials, Showcase, Known Issues