Building adapters
This guide covers the conventions used by the official adapters. Follow these patterns whether you're contributing to the official adapters package or building your own.
Adapter structure
Each adapter follows this directory layout:
Linear/
├── LinearProvider.php # Main provider implementing contracts
├── LinearClient.php # Wraps the SDK, bootstraps credentials
├── LinearCredentials.php # Spatie Data class for credentials
├── LinearMetadata.php # Spatie Data class for metadata
├── LinearResource.php # Abstract base for resource classes
├── Data/ # Spatie Data DTOs for API responses
├── Resources/ # Concrete resource implementations
├── Enums/ # Enums for statuses, types, etc.
└── Events/ # Dispatchable events for syncProvider class
The provider implements IntegrationProvider plus whichever optional contracts make sense:
class LinearProvider implements
IntegrationProvider,
HasIncrementalSync,
HasHealthCheck,
RedactsRequestData
{
// ...
}Common combinations:
| Scenario | Contracts |
|---|---|
| Read-only sync | HasIncrementalSync + HasHealthCheck + RedactsRequestData |
| Full CRUD + sync | Add CustomizesRetry if the SDK throws custom exceptions |
| OAuth + sync | Add HasOAuth2 |
| Webhooks only | HandlesWebhooks + HasHealthCheck |
Client class
The client wraps the third-party SDK with lazy initialization:
class LinearClient
{
private ?SdkClient $sdk = null;
public function __construct(
private readonly Integration $integration,
) {}
private function boot(): SdkClient
{
if ($this->sdk === null) {
$credentials = $this->integration->credentials;
// Validate types at runtime
assert($credentials instanceof LinearCredentials);
$this->sdk = new SdkClient($credentials->api_key);
}
return $this->sdk;
}
}Patterns to follow:
- Lazy-load the SDK on first use via a
boot()method - Validate credential/metadata types at bootstrap time
- Expose resource access via methods:
$client->issues(),$client->users()
Resource classes
Resources handle the actual API calls. They go through the fluent at()->...->get() builder (or the underlying Integration::request()) so everything is logged, rate-limited, and health-tracked:
class LinearIssues extends LinearResource
{
use HandlesErrors;
public function get(string $id): LinearIssueData
{
return $this->integration
->at("issues/{$id}")
->as(LinearIssueData::class)
->get(fn () => $this->sdk->issues()->find($id));
}
public function since(string $cursor, Closure $callback): void
{
// Paginate through results, calling $callback for each item
}
}The HandlesErrors concern provides executeWithErrorHandling() for try/catch with logging.
Input validation
Callers are internal application code, but the adapter is still a boundary worth guarding. For IDs, amounts, limits, and any value the upstream would reject with an opaque error, fail fast locally so the stack trace points at the actual caller. Keep a small base class for shared helpers rather than repeating guards in every method:
abstract class StripeResource
{
// ... constructor, sdk() accessor ...
protected function assertId(string $id): void
{
if ($id === '') {
throw new InvalidArgumentException('Stripe resource id cannot be empty.');
}
}
protected function assertPositive(int $value, string $parameter): void
{
if ($value <= 0) {
throw new InvalidArgumentException(sprintf(
'Stripe %s must be positive, got %d.',
$parameter,
$value,
));
}
}
}Then each method validates its inputs before building the request:
public function retrieve(string $id): Refund
{
$this->assertId($id); // '' would otherwise build `refunds/`, hitting the list endpoint
return $this->expectInstance(
$this->integration->at("refunds/{$id}")->get(...),
Refund::class,
);
}Two reasons this matters:
- Empty-string IDs silently route to the list endpoint of most REST APIs; the
assertId()check catches the common footgun (uninit variable,?? ''fallback in caller code) before the URL is built. - Non-positive limits and amounts would be rejected by the upstream anyway, but a local
InvalidArgumentExceptionpoints at the caller directly rather than surfacing as an opaque API error.
Idempotency keys for non-idempotent writes
The core retries GET requests 3 times and non-GET requests once. If a POST reaches the upstream but the response is lost in transit, the retry re-runs the closure and creates a second record: a duplicate refund, a duplicate charge capture, a duplicate dispatched message.
For any state-changing POST where a duplicate would be bad, accept an optional idempotency key parameter on the resource method and pass it through to the builder. Core handles the generation, validation, and persistence:
public function create(
int $amount,
string $currency,
// ...
?string $idempotencyKey = null,
): PaymentIntent {
$this->assertPositive($amount, 'amount');
return $this->expectInstance(
$this->integration
->at('payment_intents')
->withData($params)
->withIdempotencyKey($idempotencyKey) // null -> auto-UUID, '' -> throws
->post(function (RequestContext $ctx) use ($params): PaymentIntent {
return $this->sdk()->paymentIntents->create(
$params,
['idempotency_key' => $ctx->idempotencyKey],
);
}),
PaymentIntent::class,
);
}Three things to get right:
- Read the key from
$ctx->idempotencyKeyinside the closure. Core resolves the key once per call and preserves it across inner retries, so all attempts submit the same value to the upstream. - Callers re-issuing the same operation across job runs (e.g. a queued job that retries after a crash) should pass a stable key derived from the originating domain event. The auto-generated UUID only protects transient retries inside one adapter call, not retries across separate calls.
- If the upstream API natively dedupes by key, mark the provider with
SupportsIdempotencyso core doesn't warn about decorative keys. Plumbing the key through is still useful for adapters that don't implement that contract: the value persists onintegration_requests.idempotency_keyfor audit, even when the upstream ignores it.
See Idempotency for the consumer-facing picture.
Capturing provider request IDs
Most provider APIs return a request ID in the response headers (Stripe Request-Id, GitHub X-GitHub-Request-Id, Zendesk X-Zendesk-Request-Id). Capturing that into integration_requests.provider_request_id makes "what happened on call X" trivially debuggable when filing a support ticket against the provider.
The RequestContext argument exists for this. Inside the closure, after the SDK call returns, report whatever metadata you can extract:
$response = $this->integration
->at("charges/{$id}")
->get(function (RequestContext $ctx) use ($id): Charge {
$charge = $this->sdk()->charges->retrieve($id);
// Stripe SDK exposes the last response (and its headers) via the
// client. Other SDKs vary: GitHub's knplabs lib has getLastResponse()
// returning a PSR ResponseInterface; Postmark and Zendesk currently
// hide headers entirely.
$last = $this->sdk()->getLastResponse();
if ($last !== null) {
$ctx->reportResponseMetadata(
providerRequestId: $last->headers['Request-Id'] ?? null,
);
}
return $charge;
});Helper on the resource base class keeps the per-call code tight:
protected function reportStripeMetadata(RequestContext $ctx): void
{
$last = $this->sdk()->getLastResponse();
if ($last === null) return;
$ctx->reportResponseMetadata(
providerRequestId: $last->headers['Request-Id'] ?? null,
);
}reportResponseMetadata() also accepts rateLimitRemaining, rateLimitResetAt, and retryAfterSeconds. When provided, those feed the adaptive rate limiter, so the next request is suppressed until the upstream's reset window passes. GitHub's X-RateLimit-Remaining / X-RateLimit-Reset are good candidates here; Stripe only emits limit headers on 429s.
If the SDK doesn't expose headers at all, the column stays null for that adapter. Fine, just document the limitation in the adapter's page.
Return types
Two paths for typing resource responses, depending on what the SDK gives you back:
| SDK behaviour | Return | Example |
|---|---|---|
| Already returns typed classes (phpdoc or native) | The SDK type directly | Stripe (\Stripe\Refund) |
| Returns arrays or loosely-typed objects | Local Spatie Data class | GitHub (GitHubIssueData), Zendesk (ZendeskTicketData) |
Wrapping an already-typed SDK response in a local Data class duplicates work without adding anything. Wrapping a raw-array response gives you strong typing, a place to normalise odd shapes, and a stable surface that survives SDK swaps.
Returning SDK types directly
Integration::request() returns mixed because the closure can return anything the pipeline needs to log. Narrow back to the expected type with a runtime check rather than PHPDoc annotations. A generic helper on the resource base class handles this once:
/**
* @template T of object
* @param class-string<T> $class
* @return T
*/
protected function expectInstance(mixed $value, string $class): object
{
if (! $value instanceof $class) {
throw new UnexpectedValueException(sprintf(
'Expected instance of %s, got %s.',
$class,
get_debug_type($value),
));
}
return $value;
}For list endpoints, most SDKs expose a typed Collection class (e.g. \Stripe\Collection<\Stripe\Refund>). Narrow to that rather than iterating into a plain list<T>, so callers keep access to pagination metadata.
Serialization for request logging still works: objects that implement JsonSerializable (like \Stripe\StripeObject) are JSON-encoded by the pipeline before being stored.
Wrapping in local Data classes
When the SDK returns raw arrays, use Spatie Laravel Data:
class LinearIssueData extends Data
{
public function __construct(
public readonly string $id,
public readonly string $title,
public readonly string $state,
public readonly ?LinearUserData $assignee,
public readonly array $original, // store original API response
) {}
public static function prepareForPipeline(array $properties): array
{
// Transform raw API response into constructor-friendly shape
return [
'id' => $properties['id'],
'title' => $properties['title'],
'state' => $properties['state']['name'] ?? $properties['state'],
'assignee' => $properties['assignee'] ?? null,
'original' => $properties,
];
}
}Patterns to follow:
- Store the original API response in an
originalproperty for debugging. - Use
prepareForPipeline()to transform raw API responses. - Extract nested data (attachments from HTML, fallback values, etc.) in the pipeline.
Events
Each synced item gets its own event. It must extend Integrations\Sync\SyncItemEvent, the base class the framework uses to identify per-item events:
use Integrations\Sync\SyncItemEvent;
class LinearIssueSynced extends SyncItemEvent
{
public function __construct(
public readonly Integration $integration,
public readonly LinearIssueData $issue,
) {}
}That's the only event an adapter ships. The core fires SyncCompleted once a run reconciles and SyncItemFailed when an item exhausts its retries, so adapters no longer define their own per-failure or per-completion events.
Sync pattern
A provider's sync() / syncIncremental() enumerates the items to sync and hands each one to $session->dispatch(). It doesn't process items, touch the cursor, or return a result. The framework wraps each item in a queued job, batches them, runs the listeners, and advances the cursor once every job has succeeded.
use Integrations\Concerns\ReducesCheckpointsByMax;
use Integrations\Sync\SyncSession;
class LinearProvider implements IntegrationProvider, HasIncrementalSync
{
use ReducesCheckpointsByMax;
public function syncIncremental(Integration $integration, SyncSession $session): void
{
$client = new LinearClient($integration);
$startTime = $session->cursor() ?? now()->subDay()->toIso8601String();
// Subtract an overlap buffer to catch items updated between syncs.
$bufferedStart = Carbon::parse($startTime)->subHour()->toIso8601String();
$client->issues()->since($bufferedStart, function ($issue) use ($integration, $session): void {
$session->dispatch(
new LinearIssueSynced($integration, $issue),
checkpointValue: $issue->updated_at,
externalId: (string) $issue->id,
);
});
}
// sync() (full re-sync), defaultSyncInterval(), defaultRateLimit()...
}Things to get right:
- Subtract an overlap buffer from the cursor (1 hour in the official adapters). The framework's monotonic cursor advance means re-presenting items inside that window is safe; it won't regress progress.
- Pass each item's checkpoint value (its
updated_at, an id) so the framework can reduce the run into the next cursor.use ReducesCheckpointsByMax;gives you the common "max wins" reduction; overridereduceCheckpoints()for non-comparable cursors. - Don't track success/failure or advance the cursor yourself; the framework does both from the
integration_sync_itemsrows. - Consumers should use
upsertByExternalId()in their listeners since overlap is expected, and their listeners must not implementShouldQueue(see Scheduled syncs).
There's no longer any need to checkpoint the cursor mid-iteration: per-item tracking is the checkpoint. If the enumerating job is SIGKILLed or times out, the next run starts over from the unchanged cursor; once the batch is dispatched, the cursor advances per completed item.
Auto-registration
Adapter packages can auto-register their providers so users don't need to manually edit config/integrations.php. Ship a Laravel service provider that calls IntegrationManager::registerDefaults():
<?php
namespace Integrations\Adapters;
use Illuminate\Support\ServiceProvider;
use Integrations\Adapters\GitHub\GitHubProvider;
use Integrations\Adapters\Zendesk\ZendeskProvider;
use Integrations\IntegrationManager;
class IntegrationAdaptersServiceProvider extends ServiceProvider
{
public function register(): void
{
IntegrationManager::registerDefaults([
'github' => GitHubProvider::class,
'zendesk' => ZendeskProvider::class,
]);
}
}Then register it for Laravel's package auto-discovery in your composer.json:
"extra": {
"laravel": {
"providers": [
"Integrations\\Adapters\\IntegrationAdaptersServiceProvider"
]
}
}With this in place, composer require is all the user needs. Users can still override any key in their published config if they want a custom provider class.
Contributing to the official package
To add a new adapter to pocketarc/laravel-integrations-adapters:
- Create the adapter directory under
src/Linear/(using your service's name). - Follow the patterns above.
- Add a
README.mdinside your adapter directory. - Add tests under
tests/Unit/Linear/. - Add your provider to
IntegrationAdaptersServiceProvider::register()so it's auto-registered. - Open a PR.
Releasing a community adapter
If you prefer to maintain your own package:
- Create a new Composer package.
- Require
pocketarc/laravel-integrationsas a dependency. - Follow the same patterns for consistency.
- Ship a service provider that calls
IntegrationManager::registerDefaults()(see Auto-registration above). - Register it for auto-discovery in your
composer.json. - Submit your adapter for listing on these docs by opening an issue on the laravel-integrations repository.