Skip to main content
This guide documents the simplified architecture and patterns for implementing new SDKs using Saloon with raw JSON responses. Minimum requirement: Every connector must expose a raw endpoint (a raw() method returning a RawResource that can send arbitrary requests to the API). This allows consumers to call any API endpoint even when no dedicated resource method exists. All other endpoints and resources are optional and can be added as needed. 💡 Pro Tip for AI Agents: Always start by examining the closest existing implementation to your target API. All SDKs live under src/Connectors/. For the shared GET-request pattern (one GetRequest per connector, URL and headers in the resource), study Telavox or MicrosoftGraph. 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 connectors:
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Service       │    │     Saloon       │    │   Raw JSON      │
│   Class         │◄──►│   Connector      │◄──►│   Responses     │
│   (Main API)    │    │   (HTTP Client)  │    │  (Performance)  │
└─────────────────┘    └──────────────────┘    └─────────────────┘
         │                       │                       │
         ▼                       ▼                       ▼
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│    Resource     │    │   Request        │    │ DataCollection  │
│    Classes      │    │   Classes        │    │    (Arrays)     │
│   (Organized)   │    │  (Endpoints)     │    │  (Collections)  │
└─────────────────┘    └──────────────────┘    └─────────────────┘

Core Components

  1. Raw endpoint (required): Every connector must provide raw() returning a RawResource with a request($url, $method, $data, $query) method so any API endpoint can be called. Implement RawRequest (accepting endpoint, method, optional body, query) and RawResource (building full URL from connector base when a path is passed). See HubSpot or Brevo for the pattern.
  2. Service Class: Main entry point using WithResources trait
  3. Resource Classes (optional): Group related functionality (e.g., DealsResource, ItemsResource)
  4. Connector: Handles HTTP connection and authentication
  5. Request Classes: One shared GetRequest per connector (endpoint + query built in the resource); separate classes for POST/PUT/DELETE or custom behaviour (e.g. Search, Update)
  6. Raw JSON: Direct API responses for maximum performance
  7. 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 (shared GetRequest)

Resources build the endpoint path and query (and optional headers) and pass them to a single GetRequest per connector. This keeps the request layer thin and avoids many small GET request classes.
<?php

namespace Inly\Core\Connectors\HubSpot\Resources;

use Illuminate\Support\Collection;
use Inly\Core\Connectors\HubSpot\Requests\GetRequest;
use Inly\Core\Connectors\HubSpot\Requests\PatchRequest;
use Inly\Core\Connectors\HubSpot\Requests\PostRequest;

class DealsResource extends BaseResource
{
    /**
     * Get all deals
     *
     * @return Collection
     */
    public function list(array $properties = [], array $associations = []): Collection
    {
        $query = [];
        if (!empty($properties)) {
            $query['properties'] = implode(',', $properties);
        }
        if (!empty($associations)) {
            $query['associations'] = implode(',', $associations);
        }
        $paginator = $this->connector->paginate(new GetRequest('/crm/v3/objects/deals', $query));
        $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
    {
        $query = [];
        if (!empty($properties)) {
            $query['properties'] = implode(',', $properties);
        }
        if (!empty($associations)) {
            $query['associations'] = implode(',', $associations);
        }

        return $this->connector->send(new GetRequest("/crm/v3/objects/deals/{$dealId}", $query))->json();
    }

    /**
     * Search deals (resource builds body, uses generic PostRequest)
     */
    public function search(array $filters = [], array $properties = []): Collection
    {
        $body = $this->buildSearchBody($filters, $properties, [], []);
        $paginator = $this->connector->paginate(new PostRequest('/crm/v3/objects/deals/search', $body));
        $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 = [])
    {
        $query = [];
        if (!empty($properties)) {
            $query['properties'] = implode(',', $properties);
        }

        return $this->connector->paginate(new GetRequest('/crm/v3/objects/deals', $query));
    }
}

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/Connectors/{Service}/{Service}Connector.php:
<?php

namespace Inly\Core\Connectors\{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 a shared GetRequest (and other request types as needed)

Use one GetRequest per connector. The resource builds the endpoint and query and passes them in. For list endpoints that use Saloon pagination, the connector’s paginator will merge pagination params (e.g. after, limit) into the request. src/Connectors/{Service}/Requests/GetRequest.php:
<?php

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

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

/**
 * Generic GET request. Resources pass endpoint and query (and optional headers).
 */
class GetRequest extends Request implements Paginatable
{
    protected Method $method = Method::GET;

    /**
     * @param  array<string, mixed>  $queryParams
     * @param  array<string, mixed>  $requestHeaders  Optional (e.g. for Microsoft Graph)
     */
    public function __construct(
        protected string $endpoint,
        protected array $queryParams = [],
        protected array $requestHeaders = []
    ) {}

    public function resolveEndpoint(): string
    {
        return $this->endpoint;
    }

    protected function defaultQuery(): array
    {
        return $this->queryParams;
    }

    protected function defaultHeaders(): array
    {
        return $this->requestHeaders;
    }
}
Use one request per HTTP method: PostRequest, PatchRequest, PutRequest, DeleteRequest. Each takes endpoint and (where applicable) body and query; the resource builds the body and passes it in. Do not create per-action classes like SearchDealsRequest or UpdateDealRequest—build the request body in the resource and call e.g. new PostRequest('/crm/v3/objects/deals/search', $body). Add a dedicated request class only for genuine edge cases (e.g. multipart, custom pagination). See HubSpot in src/Connectors/ for reference.

4. Create Resource Classes

Resources build the endpoint path and query and pass them to GetRequest. No per-endpoint GET request classes are needed. src/Connectors/{Service}/Resources/{Resource}Resource.php:
<?php

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

use Illuminate\Support\Collection;
use Inly\Core\Connectors\{Service}\Requests\GetRequest;

class {Resource}Resource extends BaseResource
{
    /**
     * Get all resources
     *
     * @return Collection
     */
    public function list(array $filters = []): Collection
    {
        $query = array_merge(['limit' => 100], $filters);
        $paginator = $this->connector->paginate(new GetRequest('/{resources}', $query));
        $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
    {
        return $this->connector->send(new GetRequest("/{resources}/{$id}", []))->json();
    }
}

5. Create WithResources Trait

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

namespace Inly\Core\Connectors\{Service};

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

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

6. Create Main Service Class

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

namespace Inly\Core\Connectors\{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\Connectors\{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

SDKs live under src/Connectors/{ServiceName}/. Use one shared GetRequest per connector and one request class per HTTP method for non-GET: PostRequest, PatchRequest, PutRequest, DeleteRequest. The resource is responsible for building the full body and passing it as an argument (e.g. new PostRequest($endpoint, $body)). Do not create multiple request classes per operation (e.g. no SearchDealsRequest, UpdateDealRequest). Add a dedicated request class only for genuine edge cases (e.g. multipart uploads, custom pagination behaviour).
src/Connectors/{ServiceName}/
├── {ServiceName}Connector.php          # HTTP connector
├── {ServiceName}.php                   # Main service class
├── WithResources.php                   # Resource accessor trait (must include raw())
├── Resources/                          # Resource classes
│   ├── BaseResource.php                # Base resource class (optional)
│   ├── RawResource.php                 # Required: raw API requests
│   └── {Resource}Resource.php          # Optional: endpoint + query + body built here
└── Requests/                           # Request classes
    ├── GetRequest.php                  # GET (endpoint + query from resource)
    ├── RawRequest.php                  # Required: arbitrary method/endpoint/body/query
    ├── PostRequest.php                 # POST (endpoint + body from resource; implement Paginatable if needed)
    ├── PatchRequest.php                # PATCH (endpoint + body from resource)
    ├── PutRequest.php                  # PUT (endpoint + body from resource)
    ├── DeleteRequest.php               # DELETE (endpoint from resource)
    └── ...                             # Only add more for genuine edge cases

BaseResource Class

<?php

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

use Inly\Core\Connectors\{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: One GetRequest per connector; one PostRequest, PatchRequest, PutRequest, DeleteRequest per connector (resource builds endpoint and body). No per-action request classes (e.g. no SearchDealsRequest, UpdateDealRequest) unless a genuine edge case.

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

All connectors must use the ThrowsConnectorExceptions trait so failed requests throw the shared Inly\Core\Connectors\Exceptions types (e.g. UnauthorizedException, NotFoundException, RequestException) instead of Saloon’s default. Use AlwaysThrowOnErrors so 4xx/5xx responses trigger this behaviour.
use Inly\Core\Connectors\Traits\ThrowsConnectorExceptions;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;

// In connector - use shared exceptions and throw on 4xx/5xx
class ServiceConnector extends Connector
{
    use AlwaysThrowOnErrors;
    use ThrowsConnectorExceptions;
}

// In service methods - wrap when needed
try {
    $response = $this->connector->send($request);
    return $response->json();
} catch (\Inly\Core\Connectors\Exceptions\Request\RequestException $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),
];

Workspace search (connectors)

Core provides an Artisan command to control whether connector code under src/Connectors/ is included in your editor’s workspace search. This keeps search results focused when you only use a subset of connectors. Enable/disable all connectors
  • Exclude all connectors from search (recommended starting point if you don’t use them):
    php artisan core:connector-workspace disable
    
    This adds "**/Connectors/*": true to search.exclude in your .code-workspace file.
  • Include all connectors in search again:
    php artisan core:connector-workspace enable
    
    This removes the Connectors exclude pattern from the workspace file.
Enable/disable a single connector
  • Exclude one connector (e.g. Brevo) from search:
    php artisan core:connector-workspace disable Brevo
    
  • Include one connector in search again:
    php artisan core:connector-workspace enable Brevo
    
When you specify a connector, the command updates a single aggregated exclude pattern so you can mix “exclude all” with “include these” per connector. The command only modifies *.code-workspace files in the project root; it does not change code or Composer. Suggested workflow: Run php artisan core:connector-workspace enable <Name> for each connector you actually use so only unused SDKs are hidden from search.

Implementation Checklist

  • Raw endpoint (required): Add RawRequest, RawResource, and raw() on WithResources so consumers can call any API endpoint. All other endpoints are optional.
  • Configuration: Create config/{service}.php
  • Connector: Create connector with authentication; use ThrowsConnectorExceptions (and AlwaysThrowOnErrors when errors should throw)
  • Requests: Create one GetRequest (endpoint + query from resource); add other request classes only for non-GET or custom behaviour
  • Resources (optional): Create resource classes extending BaseResource as needed
  • WithResources: Create trait organizing resource accessors (include raw())
  • Service: Create main service class using WithResources
  • BaseResource (optional): Create base resource with connector injection when using resources
  • 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.