Skip to main content

Saloon SDK Implementation Guide

This guide documents the simplified architecture and patterns for implementing new SDKs using Saloon with raw JSON responses. 💡 Pro Tip for AI Agents: Always start by examining the closest existing implementation to your target API. For simple token-based APIs, study HubSpot. For complex OAuth APIs, study Business Central. For multi-credential APIs, study Harvest.

Table of Contents

Architecture Overview

Our SDK architecture follows a consistent pattern across all services:
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Service       │    │     Saloon       │    │   Raw JSON      │
│   Class         │◄──►│   Connector      │◄──►│   Responses     │
│   (Main API)    │    │   (HTTP Client)  │    │  (Performance)  │
└─────────────────┘    └──────────────────┘    └─────────────────┘
         │                       │                       │
         ▼                       ▼                       ▼
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│    Resource     │    │   Request        │    │ DataCollection  │
│    Classes      │    │   Classes        │    │    (Arrays)     │
│   (Organized)   │    │  (Endpoints)     │    │  (Collections)  │
└─────────────────┘    └──────────────────┘    └─────────────────┘

Core Components

  1. Service Class: Main entry point using WithResources trait
  2. Resource Classes: Group related functionality (e.g., DealsResource, ItemsResource)
  3. Connector: Handles HTTP connection and authentication
  4. Request Classes: Define individual API endpoints
  5. Raw JSON: Direct API responses for maximum performance
  6. DataCollection: Collections using collect($arrays)

Resources Pattern

Why Use Resources?

Resources organize related API methods into logical groups, preventing the main service class from becoming unwieldy.

Before vs After

// Before: Everything in one class
class HubSpot
{
    public function getDeals() { /* ... */ }
    public function getDeal($id) { /* ... */ }
    public function getContacts() { /* ... */ }
    public function getContact($id) { /* ... */ }
    // ... 50+ more methods
}

// After: Organized with resources
class HubSpot
{
    public function deals(): DealsResource
    {
        return new DealsResource($this->connector);
    }

    public function contacts(): ContactsResource
    {
        return new ContactsResource($this->connector);
    }
}

// Usage:
$hubspot->deals()->list();          // Get deals as DataCollection<array>
$hubspot->deals()->get($id);        // Get single deal as array
$hubspot->contacts()->list();       // Get contacts as DataCollection<array>

Resource Implementation

<?php

namespace Inly\Core\Services\HubSpot\Resources;

use Illuminate\Support\Collection;;

class DealsResource extends BaseResource
{
    /**
     * Get all deals
     *
     * @return Collection
     */
    public function list(array $properties = [], array $associations = []): Collection
    {
        $paginator = $this->connector->paginate(new GetDealsRequest($properties, $associations));
        $allDeals = [];

        foreach ($paginator as $response) {
            $rawData = $response->json('results', []);
            $allDeals = array_merge($allDeals, $rawData);
        }

        return collect($allDeals);
    }

    /**
     * Get single deal
     */
    public function get(string $dealId, array $properties = [], array $associations = []): array
    {
        $response = $this->connector->send(new GetDealRequest($dealId, $properties, $associations));
        return $response->json();
    }

    /**
     * Search deals
     */
    public function search(array $filters = [], array $properties = []): Collection
    {
        $paginator = $this->connector->paginate(new SearchDealsRequest($filters, $properties));
        $allDeals = [];

        foreach ($paginator as $response) {
            $rawData = $response->json('results', []);
            $allDeals = array_merge($allDeals, $rawData);
        }

        return collect($allDeals);
    }

    /**
     * Manual pagination control
     */
    public function paginate(array $properties = [])
    {
        return $this->connector->paginate(new GetDealsRequest($properties));
    }
}

Step-by-Step Implementation

1. Create Configuration

config/{servicename}.php:
<?php

return [
    'base_url' => env('SERVICE_BASE_URL', 'https://api.service.com/v1'),
    'access_token' => env('SERVICE_ACCESS_TOKEN'),
    'timeout' => env('SERVICE_TIMEOUT', 60),
];

2. Create Connector

src/Services/{Service}/{Service}Connector.php:
<?php

namespace Inly\Core\Services\{Service};

use Saloon\Contracts\Authenticator;
use Saloon\Http\Auth\TokenAuthenticator;
use Saloon\Http\Connector;
use Saloon\Traits\Plugins\AcceptsJson;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;

class {Service}Connector extends Connector
{
    use AcceptsJson;
    use AlwaysThrowOnErrors;

    public function __construct(
        protected ?string $accessToken = null
    ) {}

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

    public function setAccessToken(string $token): self
    {
        $this->accessToken = $token;
        return $this;
    }

    protected function defaultAuth(): ?Authenticator
    {
        return $this->accessToken ? new TokenAuthenticator($this->accessToken) : null;
    }
}

3. Create Request Classes

src/Services/{Service}/Requests/Get{Resources}Request.php:
<?php

namespace Inly\Core\Services\{Service}\Requests;

use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\PaginationPlugin\Contracts\Paginatable;

class Get{Resources}Request extends Request implements Paginatable
{
    protected Method $method = Method::GET;

    public function __construct(
        protected array $filters = []
    ) {}

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

    protected function defaultQuery(): array
    {
        return array_merge([
            'limit' => 100,
        ], $this->filters);
    }
}

4. Create Resource Classes

src/Services/{Service}/Resources/{Resource}Resource.php:
<?php

namespace Inly\Core\Services\{Service}\Resources;

use Illuminate\Support\Collection;;

class {Resource}Resource extends BaseResource
{
    /**
     * Get all resources
     *
     * @return Collection
     */
    public function list(array $filters = []): Collection
    {
        $paginator = $this->connector->paginate(new Get{Resources}Request($filters));
        $allItems = [];

        foreach ($paginator as $response) {
            $rawData = $response->json('data', []);
            $allItems = array_merge($allItems, $rawData);
        }

        return collect($allItems);
    }

    /**
     * Get single resource
     */
    public function get(string $id): array
    {
        $response = $this->connector->send(new Get{Resource}Request($id));
        return $response->json();
    }
}

5. Create WithResources Trait

src/Services/{Service}/WithResources.php:
<?php

namespace Inly\Core\Services\{Service};

use Inly\Core\Services\{Service}\Resources\{Resource}Resource;

trait WithResources
{
    public function {resources}(): {Resource}Resource
    {
        return new {Resource}Resource($this->connector);
    }
}

6. Create Main Service Class

src/Services/{Service}/{Service}.php:
<?php

namespace Inly\Core\Services\{Service};

use Exception;

class {Service}
{
    use WithResources;

    protected {Service}Connector $connector;

    public function __construct(
        protected ?string $accessToken = null
    ) {
        $this->connector = new {Service}Connector($this->accessToken);
    }

    public function setAccessToken(string $accessToken): self
    {
        $this->accessToken = $accessToken;
        $this->connector->setAccessToken($accessToken);
        return $this;
    }

    public function isConfigured(): bool
    {
        return !empty($this->accessToken);
    }

    public function connector(): {Service}Connector
    {
        return $this->connector;
    }

    protected function ensureConfigured(): void
    {
        if (!$this->isConfigured()) {
            throw new Exception('{Service} SDK is not configured.');
        }
    }
}

7. Create Temporary test command

Do this while developing to verify that your SDK actually works. Ask the user to configure the .env variables correctly so that you can hit the live API. After you’re done, delete the test command. app/Console/Commands/Tests/Test{Service}Command.php:
<?php

namespace App\Console\Commands\Tests;

use Inly\Core\Services\{Service}\{Service};
use Exception;
use Illuminate\Console\Command;

class Test{Service}Command extends Command
{
    protected $signature = 'test:{service}';
    protected $description = 'Test the {Service} SDK';

    public function handle({Service} ${service}): int
    {
        try {
            if (!${service}->isConfigured()) {
                $this->error('❌ {Service} SDK is not configured.');
                return Command::FAILURE;
            }

            $this->info('✅ {Service} SDK is configured');

            // Test basic functionality
            $resources = ${service}->{resources}()->list();
            $this->info("Found {$resources->count()} resources");

            if ($resources->count() > 0) {
                $first = $resources->first();
                $this->line("First resource: " . json_encode($first, JSON_PRETTY_PRINT));
            }
        } catch (Exception $e) {
            $this->error('❌ Test failed: ' . $e->getMessage());
        }
    }
}

Templates

Directory Structure

src/Services/{ServiceName}/
├── {ServiceName}Connector.php          # HTTP connector
├── {ServiceName}.php                   # Main service class
├── WithResources.php                   # Resource accessor trait
├── Resources/                          # Resource classes
│   ├── BaseResource.php               # Base resource class
│   └── {Resource}Resource.php          # Specific resources
└── Requests/                          # Request classes
    ├── Get{Resources}Request.php       # List resources
    └── Get{Resource}Request.php        # Single resource

BaseResource Class

<?php

namespace Inly\Core\Services\{Service}\Resources;

use Inly\Core\Services\{Service}\{Service}Connector;

abstract class BaseResource
{
    protected readonly {Service}Connector $connector;

    public function __construct({Service}Connector $connector)
    {
        $this->connector = $connector;
    }
}

Best Practices

Naming Conventions

  • Service classes: {ServiceName} (e.g., HubSpot, BusinessCentral)
  • Connectors: {ServiceName}Connector
  • Resources: {Resource}Resource (e.g., DealsResource, ItemsResource)
  • Requests: {Action}{Resource}Request (e.g., GetDealsRequest, CreateDealRequest)

Method Naming

  • List multiple: list() → returns DataCollection<array>
  • Get single: get($id) → returns array
  • Search: search($filters) → returns DataCollection<array>
  • Manual pagination: paginate() → returns OffsetPaginator

Performance Patterns

// CORRECT: Direct array merging
foreach ($paginator as $response) {
    $rawData = $response->json('results', []);
    $allItems = array_merge($allItems, $rawData);
}
return collect($allItems);

// INCORRECT: Unnecessary transformations (old pattern)
foreach ($paginator as $response) {
    $rawData = $response->json('results', []);
    $items = array_map(fn($item) => SomeData::from($item), $rawData);
    $allItems = array_merge($allItems, $items);
}

Error Handling

use Saloon\Traits\Plugins\AlwaysThrowOnErrors;

// In connector - automatically throws on 4xx/5xx
class ServiceConnector extends Connector
{
    use AlwaysThrowOnErrors;
}

// In service methods - wrap when needed
try {
    $response = $this->connector->send($request);
    return $response->json();
} catch (Exception $e) {
    throw new ServiceException("API error: {$e->getMessage()}");
}

Configuration

// Environment variables
SERVICE_BASE_URL=https://api.service.com/v1
SERVICE_ACCESS_TOKEN=your_token_here
SERVICE_TIMEOUT=60

// Config file
return [
    'base_url' => env('SERVICE_BASE_URL', 'https://api.service.com/v1'),
    'access_token' => env('SERVICE_ACCESS_TOKEN'),
    'timeout' => env('SERVICE_TIMEOUT', 60),
];

Implementation Checklist

  • Configuration: Create config/{service}.php
  • Connector: Create connector with authentication
  • Requests: Create request classes implementing Paginatable
  • Resources: Create resource classes extending BaseResource
  • WithResources: Create trait organizing resource accessors
  • Service: Create main service class using WithResources
  • BaseResource: Create base resource with connector injection
  • Testing: Create test command
  • Performance: Use array_merge($items, $rawData) pattern
  • DataCollection: Use collect($arrays)
This simplified approach focuses on performance and maintainability while providing a clean, organized API structure.