andy87 / php-client-sdk
Base abstractions for typed PHP API clients.
Requires
- php: >=8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-05-10 12:29:31 UTC
README
Base abstractions for building typed PHP API clients.
Overview
andy87/php-client-sdk provides a small set of reusable building blocks for API client SDKs:
- prompt DTOs for request method, endpoint, path parameters, query parameters, body and validation;
- response DTOs for normalized response data, status code, headers and API errors;
- provider base class for executing typed API methods;
- pluggable authorization strategies;
- pluggable HTTP transport with a native PHP stream implementation.
The package does not generate API clients and does not depend on a specific HTTP client library.
Requirements
- PHP 8.1 or higher.
- Composer.
Installation
composer require andy87/php-client-sdk
Core Concepts
The package separates an API call into three parts:
PromptInterfacedescribes an outgoing request.ResponseInterfacedescribes a typed API response.AbstractProviderconnects prompts, responses, authorization and HTTP transport.
NativeHttpTransport can be used without extra dependencies. If a project needs another transport, implement HttpTransportInterface.
Prompt DTO
Extend AbstractPrompt to describe a request. The base class hydrates declared properties from input data, validates required fields, builds path/query/body arrays and normalizes nested DTO values through toArray() or toValue() when those methods exist.
Use PublicPrompt for public endpoints and PrivatePrompt for private endpoints with an authorization profile. AbstractPrompt remains the generic base class for custom prompt schemes.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Prompt\AbstractPrompt; /** * Describes a request for loading one user by identifier. */ final class GetUserPrompt extends AbstractPrompt { protected const METHOD = 'GET'; protected const ENDPOINT = '/users/{id}'; protected const FIELD_MAP = [ 'id' => 'id', 'includePosts' => 'include_posts', ]; protected const REQUIRED_FIELDS = ['id']; protected const PATH_FIELDS = ['id']; protected const QUERY_FIELDS = ['includePosts']; protected const BODY_FIELDS = []; protected const CONTENT_TYPE = null; public int $id; public ?bool $includePosts = null; }
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Prompt\PrivatePrompt; use and_y87\PhpClientSdk\Prompt\PublicPrompt; /** * Describes a public health-check request. */ final class HealthPrompt extends PublicPrompt { protected const METHOD = 'GET'; protected const ENDPOINT = '/health'; } /** * Describes a private order creation request. */ final class CreateOrderPrompt extends PrivatePrompt { protected const METHOD = 'POST'; protected const ENDPOINT = '/orders'; protected const AUTHORIZATION_PROFILE = 'orders-api'; }
Response DTO
Extend AbstractResponse to describe data returned by the API. On successful responses the base class hydrates properties listed in FIELD_MAP and validates REQUIRED_FIELDS. On HTTP errors it stores ApiError and skips required-field validation.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Response\AbstractResponse; /** * Contains user data returned by the API. */ final class GetUserResponse extends AbstractResponse { protected const FIELD_MAP = [ 'id' => 'id', 'name' => 'name', ]; protected const REQUIRED_FIELDS = ['id', 'name']; public int $id; public string $name; }
Provider Usage
Extend AbstractProvider and expose public methods for concrete API operations. The protected request() method validates the prompt, adds authorization headers when required, sends the HTTP request and returns the requested response DTO.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Provider\AbstractProvider; /** * Provides typed access to user API methods. */ final class UsersProvider extends AbstractProvider { /** * Loads one user by identifier. * * @param int $id User identifier. * * @return GetUserResponse Typed API response. * * @throws InvalidArgumentException When prompt validation fails. * @throws RuntimeException When authorization or transport fails. * @throws UnexpectedValueException When a successful response misses required fields. */ public function getUser(int $id): GetUserResponse { return $this->request( new GetUserPrompt(['id' => $id]), GetUserResponse::class, ); } }
Create the provider with a base URL, authorization strategy and transport:
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Auth\NullAuthorizationStrategy; use and_y87\PhpClientSdk\Config\ClientOptions; use and_y87\PhpClientSdk\Http\NativeHttpTransport; use and_y87\PhpClientSdk\Retry\DefaultRetryPolicy; $provider = new UsersProvider( baseUrl: 'https://api.example.com', authorizationStrategy: new NullAuthorizationStrategy(), transport: new NativeHttpTransport(), options: new ClientOptions( timeout: 30, retryPolicy: new DefaultRetryPolicy(maxAttempts: 2), ), ); $response = $provider->getUser(123); if ($response->hasError()) { $error = $response->getError(); echo $error?->message ?? 'API request failed.'; } echo $response->getStatusCode();
Client Options
ClientOptions is the main extension point. If it is not passed, the SDK uses safe defaults: JSON requests and responses, strict successful response validation, native no-retry policy and default request factory.
Configurable parts:
timeout,headers,events;strictValidation;validatePrompt;retryPolicy;queryEncoder;bodyEncoder;responseDecoder;errorFactory;requestFactory.authorizationResolver;refreshAuthorizationStatusCodes.
Retry is disabled by default. Use DefaultRetryPolicy only when repeated requests are safe for the target API operation.
validatePrompt controls local prompt validation before a request is built. It is enabled by default. Set it to false only in mock or test environments where a client must return success fixtures for incomplete input:
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Config\ClientOptions; $options = new ClientOptions( strictValidation: true, validatePrompt: false, );
refreshAuthorizationStatusCodes defaults to [401]. If the selected authorization strategy implements RefreshableAuthorizationStrategyInterface, the provider refreshes authorization and retries the request once after these statuses. Pass an empty list to disable this behavior.
Use BaseUrl when a client wants to configure protocol, host, port and path prefix separately:
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Config\BaseUrl; $baseUrl = new BaseUrl( host: 'api.example.com', protocol: 'https', prefix: 'api/v1', );
Runtime Events and Headers
ClientRuntime stores default request headers and event listeners shared by a client and its providers. Pass the same runtime object to providers that must share headers and listeners.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Event\BeforeRequestEvent; use and_y87\PhpClientSdk\Event\ClientEvents; use and_y87\PhpClientSdk\Runtime\ClientRuntime; $runtime = new ClientRuntime( headers: [ 'X-Client' => 'crm', ], events: [ ClientEvents::BEFORE_REQUEST => static function (BeforeRequestEvent $event): void { $event->request->headers['X-Trace-Id'] = bin2hex(random_bytes(8)); }, ], ); $runtime->addHeaders([ 'X-Account' => 'main', ]);
Supported events:
ClientEvents::AFTER_INITafter a concrete client finishes initialization.ClientEvents::BEFORE_REQUESTbefore transport sends a mutableHttpRequest.ClientEvents::AFTER_REQUESTafter raw HTTP response is converted to a typed response DTO.ClientEvents::REQUEST_EXCEPTIONafter transport, JSON decoding or response DTO construction fails.
Header names are merged case-insensitively. Authorization headers override default runtime headers, and BEFORE_REQUEST listeners can still mutate the final request.
Authorization
Use NullAuthorizationStrategy for public APIs:
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Auth\NullAuthorizationStrategy; $authorization = new NullAuthorizationStrategy();
Use ClientCredentialsAuthorizationStrategy for OAuth client_credentials. The strategy requests an access token through the configured transport and caches it until it expires. By default, the token is stored in process memory.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Auth\ClientCredentialsAuthorizationStrategy; $authorization = new ClientCredentialsAuthorizationStrategy( tokenUrl: 'https://auth.example.com/oauth/token', clientId: 'client-id', clientSecret: 'client-secret', scope: 'users.read', timeout: 30, );
Pass CacheInterface when the token must outlive the current PHP process. The SDK ships ArrayCache for memory scenarios and SimpleCacheAdapter for plugging in PSR-16/simple-cache compatible stores without adding a direct dependency on a concrete framework.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Auth\ClientCredentialsAuthorizationStrategy; use and_y87\PhpClientSdk\Cache\SimpleCacheAdapter; $authorization = new ClientCredentialsAuthorizationStrategy( tokenUrl: 'https://auth.example.com/oauth/token', clientId: 'client-id', clientSecret: 'client-secret', tokenCache: new SimpleCacheAdapter($psr16Cache), tokenCacheKey: 'oauth:example:client-id', clockSkew: 60, );
External cache payloads store access_token and expires_at. clockSkew controls early refresh: with 60, the strategy stops using the token 60 seconds before expires_at.
ClientCredentialsAuthorizationStrategy refreshes its cached token when a provider receives a configured refresh status, 401 by default, and then the provider retries the original request once.
Other built-in strategies:
BearerTokenAuthorizationStrategyfor a static Bearer token;BasicAuthorizationStrategyfor HTTP Basic auth;ApiKeyAuthorizationStrategyfor header or query API keys;CallbackAuthorizationStrategyfor project-specific authorization headers.
Prompts require authorization by default. Override the prompt constant when a request is public:
protected const AUTHORIZATION_REQUIRED = false;
Use an authorization resolver when different operations require different authorization strategies:
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Auth\ApiKeyAuthorizationStrategy; use and_y87\PhpClientSdk\Auth\AuthorizationProfileStrategyResolver; use and_y87\PhpClientSdk\Auth\PromptClassAuthorizationStrategyResolver; use and_y87\PhpClientSdk\Config\ClientOptions; $options = new ClientOptions( authorizationResolver: new PromptClassAuthorizationStrategyResolver([ GetUserPrompt::class => new ApiKeyAuthorizationStrategy('X-Api-Key', 'secret'), ]), );
For PrivatePrompt subclasses, prefer profile names such as default, avito-client-credentials, api-key or sandbox-token:
$options = new ClientOptions( authorizationResolver: new AuthorizationProfileStrategyResolver([ 'orders-api' => new ApiKeyAuthorizationStrategy('X-Api-Key', 'secret'), ]), );
HTTP Transport
NativeHttpTransport sends requests through PHP stream wrappers. It supports:
- query parameters;
- JSON request bodies;
application/x-www-form-urlencodedrequest bodies;multipart/form-datarequest bodies throughMultipartFile;- already encoded raw request bodies;
- response status code and headers;
- JSON response decoding through
HttpResponse::json().
Custom transports must implement HttpTransportInterface:
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Contracts\HttpTransportInterface; use and_y87\PhpClientSdk\Http\HttpRequest; use and_y87\PhpClientSdk\Http\HttpResponse; /** * Sends HTTP requests through an application-specific client. */ final class CustomTransport implements HttpTransportInterface { /** * Sends an HTTP request. * * @param HttpRequest $request Request data. * * @return HttpResponse Response data. * * @throws RuntimeException When the request cannot be sent. */ public function send(HttpRequest $request): HttpResponse { throw new RuntimeException('Implement transport integration here.'); } }
Mock Transport
MockTransport returns configured HttpResponse fixtures and never falls back to real network requests. Use it for test stands where a client must return successful API-shaped data without calling the external service.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Auth\NullAuthorizationStrategy; use and_y87\PhpClientSdk\Config\ClientOptions; use and_y87\PhpClientSdk\Mock\MockTransport; use and_y87\PhpClientSdk\Mock\PromptClassMockResponseResolver; use and_y87\PhpClientSdk\Mock\RouteMockResponseResolver; $resolver = (new RouteMockResponseResolver()) ->addJson('GET', '/users/{id}', [ 'id' => 123, 'name' => 'Mock User', ]); $provider = new UsersProvider( baseUrl: 'https://api.example.com', authorizationStrategy: new NullAuthorizationStrategy(), transport: new MockTransport($resolver), options: new ClientOptions(validatePrompt: false), );
Routes match by HTTP method and absolute URL, path or endpoint template stored in request metadata. OAuth token requests can be mocked by absolute token URL:
$resolver->addJson('POST', 'https://auth.example.com/oauth/token', [ 'access_token' => 'mock-token', 'expires_in' => 3600, ]);
validatePrompt=false disables only Prompt::validate(). Request building can still fail when a prompt cannot provide a method, endpoint or required path placeholder.
If route paths are unstable or generated, use PromptClassMockResponseResolver to bind fixtures to Prompt DTO classes:
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Mock\MockTransport; use and_y87\PhpClientSdk\Mock\PromptClassMockResponseResolver; $resolver = (new PromptClassMockResponseResolver()) ->addJson(GetUserPrompt::class, [ 'id' => 123, 'name' => 'Mock User', ]); $provider = new UsersProvider( baseUrl: 'https://api.example.com', authorizationStrategy: new NullAuthorizationStrategy(), transport: new MockTransport($resolver), );
Traceable Transport
TraceableTransport wraps any HttpTransportInterface and records requests, responses, exceptions and duration without changing transport behavior.
<?php declare(strict_types=1); use and_y87\PhpClientSdk\Http\NativeHttpTransport; use and_y87\PhpClientSdk\Http\TraceableTransport; use and_y87\PhpClientSdk\Auth\NullAuthorizationStrategy; $transport = new TraceableTransport(new NativeHttpTransport()); $provider = new UsersProvider( baseUrl: 'https://api.example.com', authorizationStrategy: new NullAuthorizationStrategy(), transport: $transport, ); $response = $provider->getUser(123); $lastRecord = $transport->getLastRecord();
Response DTOs can also store local diagnostic notes:
$response->addDiagnostic(['source' => 'fixture', 'case' => 'empty-list']); $diagnostics = $response->getDiagnostics();
Error Handling
- Prompt validation throws
InvalidArgumentExceptionwhen a required field is missing or empty. - Request factory validation can throw
ValidationExceptionwhen an endpoint contains an unfilled path placeholder. - Authorization failures throw
AuthorizationException. - Transport failures throw
TransportException. - Successful non-JSON responses throw
ResponseDecodeException. - Response DTO construction failures throw
ResponseHydrationException. - HTTP responses with status code
400or higher are converted toApiErrorand available throughResponseInterface::getError(), including non-JSON error bodies. - Successful responses with missing required fields throw
UnexpectedValueExceptionwhenstrictValidationis enabled.
License
MIT.