juststeveking/php-sdk
A framework-agnostic PHP SDK that provides a clean, lightweight foundation for building and consuming API clients. It simplifies request handling and response structure for PHP and Laravel projects, making integrations easier to build and reuse.
Install and pin immediately.
composer require juststeveking/php-sdk:3.0.0
Pin the exact version. The 3.0.0 release notes are a single line ("Feature/new release") with one PR reference and no upgrade guide, and the package has zero dependents — no one has absorbed the breaking changes ahead of you. Lock it, then read vendor/juststeveking/php-sdk/src in full. The package is small enough that an hour of source reading replaces a week of guessing at conventions.
Read in this order before writing a line of code:
First use case: one authenticated GET, end to end. Don't scaffold your whole API surface. Pick a single read endpoint, build one client subclass, one request class, and one typed return. That path touches config, transport, request assembly, response parsing, and error mapping — every layer where the abstraction can bite. Friction shows up on endpoint one, not endpoint fifteen.
Because the package is deliberately framework-agnostic, there is no service provider, no facade, and no config file. You own the Laravel wiring:
// config/services.php
'acme' => [
'base_url' => env('ACME_BASE_URL'),
'token' => env('ACME_TOKEN'),
'timeout' => 10,
],
// AppServiceProvider::register()
$this->app->singleton(AcmeClient::class, fn () => new AcmeClient(
http: new \GuzzleHttp\Client(['timeout' => config('services.acme.timeout')]),
baseUrl: rtrim(config('services.acme.base_url'), '/'),
token: config('services.acme.token'),
));
Bind the PSR-18 client explicitly. Relying on php-http/discovery is the most common first-hour friction: it silently picks a transport, or fails with an unhelpful "no PSR-18 client found" deep inside a request.
One client subclass per remote API, many resource classes. The client owns transport concerns — base URL, auth header, timeout, Guzzle handler stack. Resources own endpoint knowledge and are constructed from the client. Keep the resource layer free of Http:: calls, config() lookups, and Eloquent. That's what makes it portable and testable.
Resources return DTOs, never arrays. An array leaking out of a resource becomes an implicit contract across your app, and every consumer re-guesses key names and nullability. Map at the boundary:
final class InvoiceResource
{
public function __construct(private AcmeClient $client) {}
public function find(string $id): Invoice
{
$response = $this->client->send(new GetInvoiceRequest($id));
return Invoice::from($response->json());
}
/** @return \Generator<int, Invoice> */
public function each(?string $cursor = null): \Generator
{
while ($cursor !== null) {
$page = $this->client->send(new ListInvoicesRequest($cursor));
foreach ($page->json('data') as $row) {
yield Invoice::from($row);
}
$cursor = $page->json('next_cursor');
}
}
}
spatie/laravel-data earns its keep here; hand-written ::from() constructors are fine for a dozen endpoints and add zero dependencies.
Encapsulate every endpoint as a named request class. Even when the base request is fluent enough to build inline, a class gives you one place to later add an idempotency key, a per-endpoint timeout, or a signature — and makes the endpoint greppable from a stack trace. A stack trace showing Acme\Sdk\Requests\GetInvoiceRequest is worth ten showing send().
Model pagination as a generator. The package leaves pagination to you. A resource method returning \Generator composes beautifully with LazyCollection for bulk syncs and keeps memory flat:
LazyCollection::make(fn () => $client->invoices()->each())
->chunk(500)
->each(fn ($batch) => InvoiceRow::upsert($batch->all(), ['id']));
Centralise error mapping in the client — once. Map non-2xx responses to a small set of your own exceptions (RateLimited, NotFound, ValidationFailed) at the single point where the response comes back. Controllers and jobs then never see PSR-7 status codes, and if you ever swap the SDK out, the blast radius is one class.
Test at the transport boundary, not the HTTP facade. This is the biggest workflow change from normal Laravel work. Http::fake() has no effect — the SDK talks to its own PSR-18 client. Use GuzzleHttp\Handler\MockHandler or a hand-rolled fake ClientInterface returning canned PSR-7 responses, and bind it in the container during tests:
$this->app->instance(AcmeClient::class, new AcmeClient(
http: new Client(['handler' => HandlerStack::create(new MockHandler([$fakeResponse]))]),
baseUrl: 'https://acme.test',
token: 'test-token',
));
Build this harness in the first week. Without it every test either hits the real network or mocks your own SDK — and mocking your own SDK tests nothing.
Wire it into places Laravel developers actually invoke APIs:
config() inside the request layer, so refresh mid-run works and tests can swap the provider.Keep your public API free of SDK types. Subclass the request/response bases, but don't type-hint them in constructors your other code depends on. The seam you want is "my client interface," implemented by a class that happens to extend the SDK. That's what makes an eventual fork or replacement cheap.
Laravel's HTTP client is not involved, and that costs more than you expect. No Http::fake(), no ->retry(), no ->throw(), no Http::pool(), no request/response events, no Telescope capture. If the client you're building is internal-only and never published, Http:: is the better tool and this package is overhead. Adopt it when the artifact genuinely needs to run outside Laravel — Symfony, Slim, plain PHP — because that portability is the only thing you're buying.
Retries, backoff, rate limiting, and token refresh are yours. The scope discipline is a feature, but it's a feature you staff. Concretely:
RetryMiddleware on the handler stack for 429/5xx with jittered exponential backoff.Retry-After explicitly rather than relying on your backoff curve.Bolt these on during the spike, not after the SDK is in production — it's materially harder later.
Read PSR-7 response streams once. If the base response exposes the body as a stream, calling getContents() twice silently returns empty on the second read. Cache the decoded payload (or cast to string once) in a thin wrapper around the response before you build twenty resources on top of it.
Trailing slashes in the base URL are a real bug source. /v1 + /invoices vs /v1/ + /invoices produces //invoices, which some gateways 404 and others normalise. Normalise in the client constructor (rtrim($base, '/')) and never concatenate by hand in resources.
Don't map SDK exceptions straight to HTTP responses. A GuzzleHttp\Exception\ConnectException escaping to a controller becomes a 500 on a user-facing page. Catch at the client boundary, rethrow domain exceptions, and let Laravel's exception handler decide the status. Decide early whether a third-party timeout during a web request should degrade gracefully — usually yes.
Version treadmill: budget real time. 3.0.0 proves majors ship without migration notes. Each one is a source-reading exercise, not a composer update. Pin exactly, upgrade deliberately, and record diff findings in your own changelog. Half a day per major is the honest estimate.
Bus factor is 1 — plan the exit before you need it. MIT plus a few thousand lines means the escape hatches are cheap:
Reconsider the whole thing if the surface grows. Two dozen stable endpoints with predictable payloads is the sweet spot. Throw in OAuth flows, webhooks with signature verification, streaming, or OpenAPI-driven contracts and the honest answer is Saloon (for HTTP concerns) or a generator (for spec fidelity), with your resource layer written in-house either way.
How can I help you explore Laravel packages today?