yuriitatur / api-client
Boilerplate for building api clients
Requires
- php: >=8.4
- guzzlehttp/guzzle: ^7.4
- psr/log: ^3.0
- yuriitatur/exceptions: dev-master
Requires (Dev)
- dg/bypass-finals: ^1.9
- kamermans/guzzle-oauth2-subscriber: ^1.0
- kevinrob/guzzle-cache-middleware: ^7.0
- kint-php/kint: ^6.0
- laravel/framework: ^13.0
- phpunit/phpunit: ^13.0
- symfony/lock: ^8.0
- symfony/rate-limiter: ^8.0
- symfony/yaml: ^8.0
- yuriitatur/laravel-logger-boost: ^1.0
Suggests
- glopgar/monolog-timer-processor: Logs request duration
- kamermans/guzzle-oauth2-subscriber: Adds middleware for Oauth2 authentication
- kevinrob/guzzle-cache-middleware: Adds middleware for caching
- symfony/lock: while using rate limiters, adds ability to eliminate race conditions
- symfony/rate-limiter: adds ability to use rate limiters
- symfony/yaml: Enables decoding Yaml responses
- yuriitatur/laravel-logger-boost: With it, you can pass log keys, that you are using in your system to add tracability
This package is auto-updated.
Last update: 2026-04-13 06:26:40 UTC
README
Basic Api Client
A boilerplate to quickly start using your own api client.
Installation
composer require yuriitatur/api-client
Usage
Configuration
use YuriiTatur\ApiClient\Services\StackBuilder; use GuzzleHttp\Client; use YuriiTatur\ApiClient\BasicApiClient; use YuriiTatur\ApiClient\ApiClientBuilder; use YuriiTatur\ApiClient\Services\Retry\MaxCountRetryStrategy; $api = (new ApiClientBuilder) ->withLogger($logger) ->withBaseUri('https://3rd-part-api.com') ->withGuzzleOptions([ # default guzzle options 'verify' => $isProduction, 'auth' => ['my-user', 'my-password'] ]) ->withCustomStack( (new StackBuilder) ->withHandler(new CurlHandler()) ->withNoExceptionConverter() ->withRetryStrategy(new MaxCountRetryStrategy(2)) ->build() )->build();- Making calls as usual
/**@var $api \YuriiTatur\ApiClient\BasicApiClient */ $api->request('GET', 'https://google.com', [ 'query' => [ 'foo' => 'bar' ], ]);
Response Parsing
Response bodies are automatically parsed based on the Content-Type header. The return value of request() is already the parsed content — no manual decoding needed.
| Content-Type | Return type |
|---|---|
application/json (and variants) | array |
application/yaml / text/yaml | array |
text/xml / application/xml | SimpleXMLElement |
| anything else | string |
YAML parsing requires symfony/yaml:
composer require symfony/yaml
You can register additional transformers by implementing ResponseBodyTransformerInterface:
use YuriiTatur\ApiClient\Services\ResponseMapping\ResponseBodyTransformerInterface;
class CsvBodyTransformer implements ResponseBodyTransformerInterface
{
public function canHandle(ResponseInterface $response): bool
{
return str_contains($response->getHeaderLine('Content-Type'), 'text/csv');
}
public function transform(string $body): mixed
{
return str_getcsv($body);
}
}
$stack->withResponseTransformers([new CsvBodyTransformer()]);
// custom transformers merge with the built-in JSON/YAML/XML ones
Async Requests
$promise = $api->requestAsync('GET', '/endpoint', ['query' => ['foo' => 'bar']]);
$parsedBody = $promise->wait();
Error Handling
By default, any 4xx/5xx response is converted to an ApiCallException. The GenericExceptionConverter extracts the error message from the response body by looking for message, error_message, or error JSON keys.
use YuriiTatur\ApiClient\Exceptions\ApiCallException;
try {
$api->request('POST', '/endpoint', ['json' => $payload]);
} catch (ApiCallException $e) {
$e->getMessage(); // extracted from response body
$e->getHttpCode(); // HTTP status code
$e->getMethod(); // 'POST'
$e->getUri(); // '/endpoint'
$e->getResponse(); // parsed response body
}
To disable exception conversion entirely:
$stack->withNoExceptionConverter();
To use a custom converter:
use YuriiTatur\ApiClient\Services\ErrorHandling\ExceptionConverter;
class MyConverter implements ExceptionConverter
{
public function convert(BadResponseException $exception): \Throwable
{
// return your own exception
}
}
$stack->withExceptionConverter(new MyConverter());
Retry Strategies
Enable retries by providing a retry strategy. Strategies are composed from a retry decision (when to retry) and a delay calculation (how long to wait).
use YuriiTatur\ApiClient\Services\Retry\MaxCountRetryStrategy;
use YuriiTatur\ApiClient\Services\Retry\ExponentialDelayStrategy;
use YuriiTatur\ApiClient\Services\Retry\MaxDelayStrategy;
$stack
->withRetryStrategy(new MaxCountRetryStrategy(maxRetries: 5)) // retry up to 5 times
->withDelayStrategy(
new MaxDelayStrategy(
new ExponentialDelayStrategy(), // 1s → 2s → 4s → 8s …
maxDelayInMilliseconds: 10_000 // capped at 10 s
)
);
Retry strategies
| Class | Constructor | Behaviour |
|---|---|---|
MaxCountRetryStrategy | maxRetries = 5 | Retries on any exception, up to N times |
Delay strategies
| Class | Constructor | Behaviour |
|---|---|---|
ConstantDelayStrategy | delayInMilliseconds = 1000 | Fixed delay every attempt |
ExponentialDelayStrategy | — | 2^(attempt-2) * 1000 ms (1 s, 2 s, 4 s, …) |
MaxDelayStrategy | DelayStrategyInterface $delegate, maxDelayInMilliseconds = 5000 | Wraps any strategy and caps the result |
DelayUntilRetryStrategy | retryAfterHeader = 'Retry-After' | Reads the Retry-After response header |
You can override limits per-request with Guzzle options:
$api->request('GET', '/endpoint', [
'max_retries' => 3,
'max_retry_delay' => 2000, // ms
]);
Custom retry strategy
Implement RetryStrategyInterface (decide when) and/or DelayStrategyInterface (decide how long):
use YuriiTatur\ApiClient\Services\Retry\RetryStrategyInterface;
class RetryOnServerError implements RetryStrategyInterface
{
public function decide(int $attempt, array $options, RequestInterface $request,
?ResponseInterface $response, ?RequestException $exception): bool
{
return $response?->getStatusCode() >= 500 && $attempt < 3;
}
}
Rate limiting Middleware
To use rate-limiting middleware, you have to install symfony/rate-limiter library.
For more advanced usage, to atomically track requests, you may want to install symfony/lock package.
First, you need to create a middlewares. You can create as many middlewares as you have different limit cases.
For example, let's create 2 middlewares to handle /v1 and /v2 routes.
composer require symfony/rate-limiter symfony/lock
use YuriiTatur\ApiClient\Middleware\RateLimitMiddleware;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\Lock\Store\InMemoryStore;
use Symfony\Component\Lock\LockFactory;
# for more at https://symfony.com/doc/current/rate_limiter.html
$factoryV1 = new RateLimiterFactory([
'id' => 'rate-limits-for-v1-routes',
'policy' => 'sliding_window',
'limit' => 100, # requests
'interval' => '1 minute',
], new InMemoryStorage(), new LockFactory(new InMemoryStore()));
$factoryV2 = new RateLimiterFactory([
'id' => 'rate-limits-for-v2-routes',
'policy' => 'sliding_window',
'limit' => 10000, # requests
'interval' => '1 hour',
], new InMemoryStorage(), new LockFactory(new InMemoryStore()));
$rateLimitV1 = new RateLimitMiddleware(
'v1-routes',
$factoryV1->create(),
$logger,
10 # this is a max waiting time to make a request reservation
);
$rateLimitV2 = new RateLimitMiddleware(
'v2-routes',
$factoryV2->create(),
$logger,
);
$stack->push($rateLimitV1, 'v1-limit');
$stack->push($rateLimitV2, 'v2-limit');
After registering those middlewares in our stack, we are ready to use it, simply pass
rate_limiter option in your request.
$api->get('https://api.domain.com/v1/users', [
'rate_limiter' => 'v1-routes',
]);
$api->get('https://api.domain.com/v2/users', [
'rate_limiter' => 'v2-routes',
]);
You may think why we need to specify limiters explicitly? If it's v1 ⇒ v1-routes. There are thousands of different combinations of request options. Some apis rate limit by api keys, some by route prefixes, others by called entities. You'll have to write your own logic. Create a new middleware and register it in a stack like so: Note that this is very simplified code.
class MatchRateLimiterMiddleware {
public function __invoke(callable $next) {
return function (RequestInterface $request, array $options) use ($next) {
if (strpos($request->getRequestTarget(), '/v1/') !== false) {
$options['rate_limiter'] = 'v1-routes';
}
if (strpos($request->getRequestTarget(), '/v2/') !== false) {
$options['rate_limiter'] = 'v2-routes';
}
return $next($request, $options);
};
}
}
$stack->before('v1-limit', new MatchRateLimiterMiddleware, 'rate-limit-matcher');
Laravel usage
This package contains a laravel service provider, being registered via composer. The first that you want to do is to publish configs.
php artisan vendor:publish --tag=api-clients-config --force
This will create a file in your config directory called api-clients.php that contains api clients configuration.
It looks something like this
return [
'clients' => [
'my-api-client' => [ # name of the client
'options' => [ # guzzle options
'base_uri' => 'https://api.domain.com',
'verify' => !env('APP_DEBUG'),
],
'response_transformers' => [], # extra response transformers
'exception_converter' => GenericExceptionConverter::class,
'tap' => null, # a simple callable that modifies your stack
'retry' => [ # retry settings
'retry_strategy' => MaxCountRetryStrategy::class,
'delay_strategy' => ConstantDelayStrategy::class,
],
],
],
'rate_limiters' => [
'default' => [ # name of the rate limiter
'storage' => CacheStorage::class, # storage of made requests
'lock_store' => RedisStore::class, # atomic lock storage
'policy' => 'sliding_window', # token_bucket, fixed_window
'limit' => 1, # number of requests
'interval' => '5 minutes', # time
'rate' => [ # token bucket config
'interval' => '2 seconds', # spawn time
'amount' => 1, # token count
],
'max_wait_time' => 5, # seconds to get a request reservation
]
],
];
All these clients are being registered, and available from DI:
app('api-client.my-api-client');
Testing
You can test usage of this api client the same way you test a regular guzzle client.
Either use MockHandler in your stack or spin-up a mock server. More on that here
To run library tests, run
composer run test
License
This code is under MIT license, read more in the LICENSE file.