# Saloon Saloon is a PHP library designed to help developers build beautiful, reusable API integrations and SDKs. Built on top of Guzzle HTTP client, it provides a modern, object-oriented approach to structuring API requests into reusable classes, keeping all API configurations in one centralized location. The library is framework-agnostic, lightweight with minimal dependencies, and works seamlessly within teams by providing a standardized approach that everyone can follow. Saloon comes packed with powerful features out of the box including request recording for testing, response caching, OAuth2 authentication flows, pagination support, concurrent request pools, middleware pipelines, and data transfer object (DTO) support. It provides multiple authentication strategies, flexible request body handling (JSON, form, multipart, XML), comprehensive mocking capabilities for testing, and automatic retry logic with exponential backoff, making it ideal for building production-ready PHP SDKs and API integrations. ## Creating a Connector A Connector represents an API service and defines the base URL and shared configuration for all requests to that API. ```php 'application/json', 'Authorization' => 'Bearer ' . $this->apiToken, ]; } protected function defaultConfig(): array { return [ 'timeout' => 30, ]; } } // Usage $forge = new ForgeConnector('your-api-token'); ``` ## Creating a Request A Request defines an individual API endpoint with its HTTP method, endpoint path, and any request-specific configuration like headers, query parameters, or body data. ```php 50, ]; } } class GetServerRequest extends Request { protected Method $method = Method::GET; public function __construct( protected int $serverId ) {} public function resolveEndpoint(): string { return '/servers/' . $this->serverId; } } // Usage $forge = new ForgeConnector('api-token'); $response = $forge->send(new GetServersRequest); $servers = $response->json(); $response = $forge->send(new GetServerRequest(12345)); $server = $response->json(); ``` ## Sending Requests and Handling Responses The Response object provides a fluent interface to access response data in various formats including JSON arrays, objects, collections, XML, and DOM elements. ```php send(new GetServersRequest); // Response status checks $response->status(); // 200 $response->ok(); // true (status === 200) $response->successful(); // true (status 2xx) $response->failed(); // false $response->clientError(); // false (status 4xx) $response->serverError(); // false (status 5xx) // Get response data in various formats $array = $response->json(); // Decoded JSON as array $name = $response->json('data.name'); // Access nested value with dot notation $object = $response->object(); // Decoded JSON as object $body = $response->body(); // Raw response body string $collection = $response->collect('data'); // Laravel Collection (requires illuminate/collections) $xml = $response->xml(); // SimpleXMLElement $crawler = $response->dom(); // Symfony DomCrawler // Headers $headers = $response->headers()->all(); $contentType = $response->header('Content-Type'); // Error handling if ($response->failed()) { $response->throw(); // Throws RequestException } // Or use callback $response->onError(function (Response $response) { logger()->error('API request failed', [ 'status' => $response->status(), 'body' => $response->body(), ]); }); // Save response body to file $response->saveBodyToFile('/path/to/file.pdf'); ``` ## JSON Request Body The HasJsonBody trait enables sending JSON payloads with automatic Content-Type header configuration. ```php $this->name, 'provider' => $this->provider, 'region' => $this->region, 'php_version' => 'php82', ]; } } // Usage $request = new CreateServerRequest('production-web', 'ocean2', 'nyc1'); // Modify body before sending $request->body()->merge([ 'database' => 'mysql', 'database_type' => 'mysql-8.0', ]); $request->body()->add('ssh_key', 'my-ssh-key'); $response = $forge->send($request); ``` ## Form Body Requests The HasFormBody trait enables sending URL-encoded form data with proper Content-Type header. ```php $this->email, 'password' => $this->password, 'remember' => true, ]; } } // Usage $response = $connector->send(new LoginRequest('user@example.com', 'secret')); $token = $response->json('access_token'); ``` ## Multipart File Uploads The HasMultipartBody trait supports file uploads and multipart form data using the MultipartValue class. ```php userId . '/avatar'; } protected function defaultBody(): array { return [ new MultipartValue( name: 'avatar', value: fopen($this->filePath, 'r'), filename: 'avatar.jpg', headers: ['Content-Type' => 'image/jpeg'] ), new MultipartValue( name: 'description', value: 'User profile avatar' ), ]; } } // Usage $response = $connector->send(new UploadAvatarRequest( userId: 123, filePath: '/path/to/avatar.jpg' )); ``` ## Authentication Methods Saloon provides multiple built-in authenticators for common authentication strategies including Bearer tokens, Basic auth, and query parameters. ```php $connector->authenticate(new TokenAuthenticator('api-token')); // Basic Auth: Authorization: Basic base64(username:password) $connector->authenticate(new BasicAuthenticator('username', 'password')); // Query Parameter: ?api_key= $connector->authenticate(new QueryAuthenticator('api_key', 'your-api-key')); // Custom Header: X-API-Key: $connector->authenticate(new HeaderAuthenticator('secret-key', 'X-API-Key')); // Per-request authentication $request = new GetUserRequest; $request->authenticate(new TokenAuthenticator('different-token')); $response = $connector->send($request); ``` ## OAuth2 Authorization Code Flow Saloon provides built-in support for OAuth2 authorization code grant with automatic token management, refresh handling, and state validation. ```php setClientId('your-client-id') ->setClientSecret('your-client-secret') ->setRedirectUri('https://your-app.com/auth/callback') ->setDefaultScopes(['profile', 'email']) ->setAuthorizeEndpoint('https://accounts.google.com/o/oauth2/v2/auth') ->setTokenEndpoint('https://oauth2.googleapis.com/token') ->setUserEndpoint('/oauth2/v1/userinfo'); } } // Step 1: Generate authorization URL $connector = new GoogleConnector; $authUrl = $connector->getAuthorizationUrl( scopes: ['https://www.googleapis.com/auth/calendar'], state: 'random-csrf-token' ); // Store state for verification $generatedState = $connector->getState(); session(['oauth_state' => $generatedState]); // Redirect user to $authUrl... // Step 2: Handle callback and exchange code for token $authenticator = $connector->getAccessToken( code: $_GET['code'], state: $_GET['state'], expectedState: session('oauth_state') ); // AccessTokenAuthenticator contains: $accessToken = $authenticator->getAccessToken(); $refreshToken = $authenticator->getRefreshToken(); $expiresAt = $authenticator->getExpiresAt(); // DateTimeImmutable // Step 3: Use authenticated requests $connector->authenticate($authenticator); $response = $connector->send(new GetCalendarEventsRequest); // Step 4: Refresh expired token if ($authenticator->hasExpired()) { $newAuthenticator = $connector->refreshAccessToken($authenticator); // Store new tokens... } ``` ## Request Mocking for Tests The MockClient allows you to mock API responses in tests using sequences, URL patterns, request classes, or connector classes. ```php 'Sam'], 200, ['X-Custom' => 'Header']), MockResponse::make(['name' => 'Alex'], 200), MockResponse::make(['error' => 'Not Found'], 404), ]); $connector = new ForgeConnector('token'); $connector->withMockClient($mockClient); $response1 = $connector->send(new GetUserRequest); // Returns Sam $response2 = $connector->send(new GetUserRequest); // Returns Alex $response3 = $connector->send(new GetUserRequest); // Returns 404 // Mock by request class $mockClient = new MockClient([ GetServersRequest::class => MockResponse::make(['servers' => []]), CreateServerRequest::class => MockResponse::make(['id' => 123], 201), ]); // Mock by connector class $mockClient = new MockClient([ ForgeConnector::class => MockResponse::make(['data' => 'forge']), StripeConnector::class => MockResponse::make(['data' => 'stripe']), ]); // Mock by URL pattern with wildcards $mockClient = new MockClient([ 'forge.laravel.com/api/*' => MockResponse::make(['matched' => true]), 'api.stripe.com/*' => MockResponse::make(['stripe' => true]), '*' => MockResponse::make(['fallback' => true]), // Wildcard fallback ]); // Assertions in tests $mockClient->assertSent(GetServersRequest::class); $mockClient->assertNotSent(DeleteServerRequest::class); $mockClient->assertSentCount(2); $mockClient->assertNothingSent(); // Custom assertion with callback $mockClient->assertSent(function ($request, $response) { return $request instanceof GetServerRequest && $request->serverId === 123 && $response->status() === 200; }); ``` ## Fixtures for Response Recording Fixtures allow you to record real API responses to files and replay them in tests, useful for snapshot testing and offline development. ```php MockResponse::fixture('user'), GetServersRequest::class => MockResponse::fixture('servers/list'), ]); $connector = new ForgeConnector('token'); $connector->withMockClient($mockClient); // First run: Makes real request and saves to tests/Fixtures/Saloon/user.json // Subsequent runs: Loads from fixture file $response = $connector->send(new GetUserRequest); // Custom fixture class with sensitive data redaction class UserFixture extends Fixture { protected function defineName(): string { return 'users/profile'; } protected function defineSensitiveHeaders(): array { return [ 'Authorization' => 'REDACTED', 'X-Api-Key' => 'REDACTED', ]; } protected function defineSensitiveJsonParameters(): array { return [ 'email' => 'redacted@example.com', 'api_token' => '***', ]; } } // Use custom fixture $mockClient = new MockClient([ GetUserRequest::class => new UserFixture, ]); ``` ## Concurrent Request Pools Request pools enable sending multiple requests concurrently with configurable concurrency limits and separate handlers for success and failure. ```php pool([ new GetServerRequest(1), new GetServerRequest(2), new GetServerRequest(3), new GetServerRequest(4), new GetServerRequest(5), ]); // Configure concurrency (max simultaneous requests) $pool->setConcurrency(3); // Handle successful responses $responses = []; $pool->withResponseHandler(function (Response $response, int $key) use (&$responses) { $responses[$key] = $response->json(); }); // Handle failures $errors = []; $pool->withExceptionHandler(function (RequestException $exception, int $key) use (&$errors) { $errors[$key] = $exception->getMessage(); }); // Send all requests and wait for completion $promise = $pool->send(); $promise->wait(); // Alternative: Use generator for memory-efficient large batches $pool = $connector->pool(function ($connector) { foreach (range(1, 1000) as $serverId) { yield new GetServerRequest($serverId); } }); // Dynamic concurrency based on pending requests $pool->setConcurrency(function (int $pendingRequests) { return $pendingRequests > 50 ? 10 : 5; }); $pool->send()->wait(); ``` ## Middleware Pipeline Saloon provides request and response middleware pipelines for intercepting, modifying, or logging requests and responses. ```php middleware()->onRequest(function (PendingRequest $pendingRequest) { logger()->info('API Request', [ 'method' => $pendingRequest->getMethod()->value, 'url' => $pendingRequest->getUrl(), 'headers' => $pendingRequest->headers()->all(), ]); // Add dynamic header $pendingRequest->headers()->add('X-Request-Id', uniqid()); return $pendingRequest; }); // Response middleware - runs after response is received $this->middleware()->onResponse(function (Response $response) { logger()->info('API Response', [ 'status' => $response->status(), 'duration' => $response->header('X-Response-Time'), ]); return $response; }); } } // Request-level middleware $request = new GetUserRequest; $request->middleware()->onRequest(function (PendingRequest $pendingRequest) { $pendingRequest->query()->add('timestamp', time()); }); // Return MockResponse from middleware to short-circuit $request->middleware()->onRequest(function (PendingRequest $pendingRequest) { if (cache()->has('user_data')) { return MockResponse::make(cache()->get('user_data')); } }); $response = $connector->send($request); ``` ## Automatic Retry Logic Configure automatic retry behavior with customizable attempts, intervals, and exponential backoff for handling transient failures. ```php getCode() === 401) { return false; } // Don't retry on validation errors if ($exception->getCode() === 422) { return false; } return true; // Retry for other errors } } // Connector-level retry configuration class ResilientConnector extends Connector { public ?int $tries = 3; public ?int $retryInterval = 1000; public ?bool $useExponentialBackoff = true; public function resolveBaseUrl(): string { return 'https://api.example.com'; } public function handleRetry(Throwable $exception, Request $request): bool { // Log retry attempt logger()->warning('Retrying request', [ 'request' => get_class($request), 'error' => $exception->getMessage(), ]); return true; } } // Usage with mock to demonstrate retry $mockClient = new MockClient([ MockResponse::make(['error' => 'timeout'], 500), MockResponse::make(['error' => 'timeout'], 500), MockResponse::make(['success' => true], 200), // Third attempt succeeds ]); $connector = new ResilientConnector; $connector->withMockClient($mockClient); $response = $connector->send(new ReliableRequest); // After 2 failed attempts, third succeeds ``` ## SDK Resources Pattern Resources provide a clean, organized way to group related API endpoints into logical units, creating an SDK-like developer experience. ```php connector->send(new GetServersRequest)->json('servers'); } public function get(int $serverId): array { return $this->connector->send(new GetServerRequest($serverId))->json(); } public function create(string $name, string $provider, string $region): array { return $this->connector ->send(new CreateServerRequest($name, $provider, $region)) ->json(); } public function delete(int $serverId): bool { return $this->connector ->send(new DeleteServerRequest($serverId)) ->successful(); } } class SitesResource extends BaseResource { public function __construct( protected Connector $connector, protected int $serverId ) { parent::__construct($connector); } public function all(): array { return $this->connector ->send(new GetSitesRequest($this->serverId)) ->json('sites'); } } // Main SDK connector with resource methods class ForgeSDK extends Connector { public function resolveBaseUrl(): string { return 'https://forge.laravel.com/api/v1'; } public function servers(): ServersResource { return new ServersResource($this); } public function sites(int $serverId): SitesResource { return new SitesResource($this, $serverId); } } // Clean SDK-like usage $forge = new ForgeSDK; $forge->authenticate(new TokenAuthenticator('api-token')); // List all servers $servers = $forge->servers()->all(); // Get specific server $server = $forge->servers()->get(12345); // Create new server $newServer = $forge->servers()->create('web-01', 'ocean2', 'nyc1'); // List sites on a server $sites = $forge->sites(12345)->all(); ``` ## Data Transfer Objects (DTOs) Transform API responses into strongly-typed data transfer objects for type safety and better IDE support. ```php json(); return new self( id: $data['id'], name: $data['name'], email: $data['email'], avatar: $data['avatar'] ?? null ); } } // DTO with response access class Server implements WithResponse { private Response $response; public function __construct( public int $id, public string $name, public string $ipAddress ) {} public function setResponse(Response $response): void { $this->response = $response; } public function getResponse(): Response { return $this->response; } } // Request with DTO resolution class GetUserRequest extends Request { protected Method $method = Method::GET; public function resolveEndpoint(): string { return '/user'; } public function createDtoFromResponse(Response $response): User { return User::fromResponse($response); } } // Usage $response = $connector->send(new GetUserRequest); // Get typed DTO $user = $response->dto(); // $user is now a User object with IDE autocomplete // Or fail if response unsuccessful $user = $response->dtoOrFail(); ``` ## Summary Saloon excels at building maintainable, testable PHP API integrations through its class-based architecture. Common use cases include creating SDKs for third-party services, integrating with payment gateways, building clients for RESTful APIs, consuming OAuth-protected resources, and wrapping internal microservices. The library's strength lies in centralizing API configuration, providing comprehensive testing support through mocking and fixtures, and offering built-in solutions for common challenges like authentication, retries, and concurrent requests. Integration patterns that work particularly well with Saloon include the Repository pattern for data access layers, the SDK pattern using Resources for organizing endpoints, and the Service pattern for encapsulating business logic around API calls. The middleware system enables cross-cutting concerns like logging, caching, and rate limiting without polluting individual requests. For Laravel applications, the official `saloonphp/laravel-plugin` package provides additional features including automatic OAuth token management, caching drivers, and Artisan commands for generating connectors and requests.