juststeveking/sdk-tools
Tools for building SDKs in Laravel and PHP. juststeveking/sdk-tools provides utilities and conventions to scaffold, structure, and maintain API client packages, cutting boilerplate so you can ship clean, consistent SDKs faster.
Install the package plus a PSR-18 client. sdk-tools is transport-agnostic and ships no HTTP client of its own, so you always pair it with one:
composer require juststeveking/sdk-tools guzzlehttp/guzzle
Check composer.json constraints before you commit — the last release is from July 2023, so verify it installs cleanly on your current PHP baseline (8.2/8.3) and that the transitive PSR-7/PSR-18 versions resolve against what the rest of your app already uses.
Expect to write the wiring yourself. There is no service provider, no auto-discovery, no config file to publish, no facade. You register bindings in your own provider. That is the whole integration surface:
// app/Providers/SdkServiceProvider.php
public function register(): void
{
$this->app->singleton(Connector::class, fn () => new Connector(
client: new \GuzzleHttp\Client(['base_uri' => config('services.acme.base_url')]),
// auth headers / default request options applied here
));
$this->app->singleton(AcmeSdk::class, fn ($app) => new AcmeSdk(
$app->make(Connector::class),
));
}
Where to look first. The source is small enough to read in an afternoon. Open src/ and identify four things before writing code: the abstract Sdk (how resources are registered), the Resource base (how verbs and paths are composed), the connector/transporter (whether it is PSR-18-compliant or a bespoke interface), and the response wrapper (what an error looks like — throws vs. returns). Those four answers determine every decision below.
First use case: one resource, one endpoint. Do not start by modelling your whole API. Pick a single read-only endpoint, define one Resource subclass, extend Sdk to expose it, bind it in the provider, and call it from a queued job. That gets you a working vertical slice in an hour and surfaces header, base-URI, and error-model problems immediately.
Wrap the SDK behind your own interface before you use it anywhere. The package is a scaffold, not a supported dependency. Define App\Sdk\Contracts\AcmeClient with the three or four methods your app actually needs, implement it with sdk-tools, and inject the interface. This keeps package-specific abstractions (Sdk, Resource, Connector) out of your controllers, jobs, and — critically — out of any public contract you might later expose.
One resource class per API surface. Group endpoints the way the third-party API documents them (Users, Invoices, Webhooks); don't create one god-resource. Keep resources thin: build the path, pick the verb, return the response. Push mapping, validation, and domain logic into an action or job layer above the SDK.
final class Users extends Resource
{
public function find(int $id): Response
{
return $this->get("users/{$id}");
}
public function all(int $page = 1): Response
{
return $this->get('users', ['page' => $page]);
}
}
Configure, don't hardcode. Base URL, token, timeout, and retry count belong in config/services.php read through env(), never literals in a resource. Because there's no package config file, this is your config file. Cache it with config:cache like any other config.
Use the caching layer deliberately. The package supports caching responses, and Laravel's Illuminate\Cache\Repository satisfies PSR-16, so you can hand it straight to the SDK:
$this->app->singleton(Connector::class, fn ($app) => new Connector(
client: new \GuzzleHttp\Client([...]),
cache: $app->make(\Illuminate\Contracts\Cache\Repository::class),
));
Only cache idempotent GETs. Namespace keys per tenant and per token, and keep TTLs short — a stale cache in an SDK is far harder to debug than a slow request.
Testing: inject a fake PSR-18 client, not Http::fake(). This is the biggest day-to-day adjustment for a Laravel developer. Http::fake() intercepts the Laravel HTTP client; it will not see these requests at all. Bind a mock PSR-18 client in your test case (a small stub returning canned PSR-7 responses, or mockery on the client interface), assert on the captured request URI and headers, and assert on the parsed domain result — not on the SDK's response object.
Expose a thin facade for ergonomics, if you want one. AcmeSdk::users()->find(1) reads nicely in a controller. Write the facade yourself against your own interface; don't let it proxy the package's Sdk class directly, or you've re-leaked the abstraction you just hid.
Normalise errors at the boundary. Map whatever the package does on 4xx/5xx into a small set of your own exceptions (AcmeNotFoundException, AcmeRateLimitException, AcmeRequestException) so the error contract is yours and a swap to Saloon or the Laravel HTTP client is a single-file change.
Add retries and timeouts at the PSR-18 client level. The toolkit won't do it for you. Guzzle middleware for Retry-After on 429/503 is worth writing once in the connector.
Accept and Content-Type headers. Small SDK helpers commonly omit them, producing 406/415 responses against real APIs that are painful to diagnose because the request "looks fine." Set them explicitly in the connector.final. If they are, compose; if they aren't, resist the urge to subclass deeply. Extending package internals is the fastest route to lock-in.Resource or Connector types appearing in your models, controllers, or API resources, stop and add the adapter.src/ as the specification and write your own short README in the wrapper so the next developer doesn't repeat the audit.How can I help you explore Laravel packages today?