jooservices / client
Strict, extensible PHP 8.5+ HTTP client wrapper for JOOservices
Requires
- php: ^8.5
- guzzlehttp/guzzle: ^7.10 || ^8.0
- jooservices/exceptions: ^0.5 || ^1.0
- psr/http-client: ^1.0
- psr/log: ^3.0
- psr/simple-cache: ^3.0
Requires (Dev)
- captainhook/captainhook: ^5.25
- captainhook/plugin-composer: ^5.3
- friendsofphp/php-cs-fixer: ^3.66
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- phpbench/phpbench: ^1.4
- phpmd/phpmd: ^2.15
- phpstan/phpstan: ^2.0
- phpstan/phpstan-mockery: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
- squizlabs/php_codesniffer: ^4.0
- symfony/filesystem: ^7.4|^8.0
- symfony/var-dumper: ^8.0
Suggests
- ext-curl: Required when using ClientBuilder::withCurlAdapter() or withCurlMultiAdapter()/buildCurlMulti()
- jooservices/dto: Optional DTO helpers for ResponseWrapper::toDto()
This package is auto-updated.
Last update: 2026-08-19 21:43:51 UTC
README
A robust, layered HTTP client wrapper designed for extensibility, strict typing, and high performance. Built with a clean, package-oriented architecture that decouples transport integration from client behavior.
The JOOservices Client is a PHP 8.5+ HTTP client package.
Package name: jooservices/client
Latest stable release: v3.0.0 (see CHANGELOG)
Features
- Strictly Typed: Configuration object (
ClientConfig) ensures type safety before requests start (validated via PHP 8.5 property hooks). - Layered Architecture: Guzzle (default), native cURL, and cURL multi transports are isolated from core logic.
- Resilience: Built-in Retry (Backoff/Jitter +
onRetryhook), Circuit Breaker (per-host/partition_keyscoping + open/close hooks), Rate Limit (serverRateLimit-Reset/Retry-Afterhints), Bulkhead, Fallback, Deadline, HTTP error mapping, and curl↔Guzzle transport failover. - Async middleware: First-party middleware chains Guzzle promises without
wait()(AsyncMiddlewareInterface). - Observability: Logging, W3C trace context, metrics (including connection reuse), correlation IDs, and
onErrorrecovery interceptors. - Auth: Bearer, API key, Basic auth, and OAuth token refresh middleware.
- Performance: < 10μs overhead per request; true concurrency via cURL multi
batch(). - Guzzle 7.10 / 8: Native Guzzle transport with stable package exceptions.
- Testing: Builder-native fakes with pattern-based stubs, ordered assertions, deterministic retry and rate-limit sleeps.
Installation
composer require jooservices/client
Runtime dependencies include Guzzle (^7.10 || ^8.0), jooservices/exceptions (^0.5 || ^1.0), and psr/log. Logging sinks (Monolog, Mongo, SQL, …) belong in the consuming application.
Native cURL transport
Use the optional native cURL transport when ext-curl is installed. Synchronous requests support package middleware and portable options. Prefer buildSync() so callers are not typed for async APIs the transport rejects.
$client = ClientBuilder::create() ->withTransport('curl') // or withCurlAdapter() ->buildSync();
For truly concurrent batching without Guzzle promises, enable the cURL multi transport:
$batchClient = ClientBuilder::create() ->withBaseUri('https://api.example.com') ->withTransport('curl_multi') // or withCurlMultiAdapter() ->buildCurlMulti(); $results = $batchClient->batch([ 'user1' => new \GuzzleHttp\Psr7\Request('GET', '/users/1'), 'user2' => new \GuzzleHttp\Psr7\Request('GET', '/users/2'), ]); // array<string, ResponseWrapper|Throwable> — failures are mapped exceptions echo $results['user1']->body();
CurlMultiBatchClient also implements HttpClientInterface, so single-request calls
(with middleware) and concurrent batch() (transport-level) share one instance. Batch
items may be RequestInterface, callable(): RequestInterface, or
[RequestInterface, options] tuples.
| Portable on cURL | Notes |
|---|---|
json, form_params, query, auth, multipart, body |
Body/StreamInterface supported |
sink, max_size, resume, stream |
Downloads; max_size is transport-agnostic |
timeout, connect_timeout, verify, cert, ssl_key |
TLS + timeouts |
allow_redirects |
max, protocols, track_redirects, strict, referer, on_redirect |
proxy, progress, cookies, version, force_ip_resolve, on_stats |
cookies = jar or name => value map; version includes HTTP/3 when libcurl supports it |
Non-portable keys (handler, curl, delay, on_headers, read_timeout) throw. Enable strict mode with withCurlAdapter(strictPortableOptions: true) to also reject unknown keys. Async / batch() remain Guzzle-only unless you fail over with withFailoverTransport('guzzle').
$client = ClientBuilder::create() ->withTransport('curl') ->withFailoverTransport('guzzle') ->build();
Quick Start
Basic Usage
Use the ClientBuilder to create an instance.
use JOOservices\Client\Client\ClientBuilder; $client = ClientBuilder::create() ->withBaseUri('https://api.example.com') ->withTimeout(5) ->withHeader('Authorization', 'Bearer token') ->build(); $response = $client->get('/users/1'); echo $response->status(); // 200 print_r($response->json()); // ['id' => 1, ...]
Downloading Files
The client supports memory-efficient file downloads. The response body is written directly to the target destination path using a stream (avoiding buffering the download in memory). Response body logging is automatically skipped for downloads.
// Synchronous download $client->download('https://example.com/largefile.zip', '/path/to/save/largefile.zip'); // Asynchronous download $promise = $client->downloadAsync('https://example.com/largefile.zip', '/path/to/save/largefile.zip'); $promise->wait();
JSON, Uploads, and Response Helpers
$client = ClientBuilder::create()->withJsonDefaults()->withRedirects(true)->build(); $response = $client->postJson('/users', ['name' => 'Ada']); if ($response->successful()) { echo $response->body(); } $client->upload('/documents', __DIR__ . '/report.pdf', ['category' => 'reports']);
Map JSON bodies to typed objects:
$dto = $response->as(UserDto::class); // alias of toDto() $obj = $response->object(); // stdClass $users = $response->collect(UserDto::class); // JSON list -> list<UserDto>
upload() accepts a readable file path and creates the multipart file part. body() preserves the
current position for seekable streams.
Testing with fakes
Production code and tests share the same ClientBuilder::create()->build() entry point — call
ClientBuilder::fake() first and everything else about how the client is built stays the same.
use JOOservices\Client\Testing\TestResponse; ClientBuilder::fake([ TestResponse::times(2, TestResponse::status(503))->then(TestResponse::ok(['id' => 1])), ]); $client = ClientBuilder::create()->withRetry(new RetryConfig())->build(); $response = $client->get('/health'); ClientBuilder::assertSentCount(3); // 2 failed attempts + 1 success, all recorded ClientBuilder::assertSent('GET', '/health'); ClientBuilder::clearFake(); // always call in tearDown(), or use InteractsWithHttpClient
TestResponse covers every outcome the real transport can produce: ok(), json(), status(),
notFound(), serverError(), timeout(), connectionError(), fatal(), and asHttpError() for
forcing an HttpResponseException regardless of the builder's withHttpErrors() setting. Retry and
rate-limit middleware automatically use a NullSleeper while faked, so retried requests don't
actually sleep. Fakes cannot be combined with a custom adapter or Guzzle handler option — build()
throws InvalidConfigurationException instead of silently hitting real network.
Route stubs by URI pattern — route queues deplete before the FIFO queue, and every routed request is recorded in the history:
ClientBuilder::fake(); ClientBuilder::respond('GET', 'https://api.example.com/users/*', TestResponse::ok(['id' => 1])); $response = ClientBuilder::create()->build()->get('https://api.example.com/users/42'); ClientBuilder::assertSentTimes(1, 'GET', 'https://api.example.com/users/42'); ClientBuilder::assertSentInOrder(['GET', 'https://api.example.com/users/42'], 'GET');
Use the InteractsWithHttpClient trait to clear the fake automatically after every test:
use JOOservices\Client\Testing\InteractsWithHttpClient; final class BillingClientTest extends TestCase { use InteractsWithHttpClient; // calls ClientBuilder::clearFake() in tearDown() }
Async calls retain the documented synchronous-middleware limitation.
Async Requests & Batching
// Single Async Request $promise = $client->getAsync('/users/1'); $response = $promise->wait(); // Batch Processing (Concurrent) $results = $client->batch([ 'user1' => fn() => $client->getAsync('/users/1'), 'user2' => fn() => $client->getAsync('/users/2'), ]); print_r($results['user1']->json());
Advanced Configuration
Resilience (Retry & Circuit Breaker)
use JOOservices\Client\Resilience\RetryConfig; use JOOservices\Client\Resilience\CircuitBreakerConfig; $client = ClientBuilder::create() ->withRetry(new RetryConfig( maxAttempts: 3, baseDelayMs: 100 )) ->withCircuitBreaker(new CircuitBreakerConfig( failureThreshold: 5, recoveryTimeoutMs: 10000 )) ->build();
Production middleware stack
Register middleware outermost-first. Use individual helpers or the preset:
use JOOservices\Client\Client\ClientBuilder; use JOOservices\Client\Resilience\RateLimitConfig; use JOOservices\Client\Resilience\RetryConfig; use JOOservices\Client\Support\InMemoryMetricsRecorder; use JOOservices\Client\ValueObjects\TraceContextConfig; $client = ClientBuilder::create() ->withBaseUri('https://api.example.com') ->withHeader('Accept', 'application/json') ->withBearerToken($token) ->withProductionMiddlewareOrder( rateLimit: new RateLimitConfig(maxTokens: 50, refillRatePerSecond: 50), traceContext: new TraceContextConfig(), metrics: new InMemoryMetricsRecorder(), retry: new RetryConfig(), ) ->build();
Per-request options: idempotency_key, deadline_ms, rate_limit_bypass, cache_bypass, cache_ttl, partition_key, fallback_enabled.
Resilience callbacks & error recovery
Observe resilience decisions or recover from failures without custom middleware:
use JOOservices\Client\Resilience\CircuitBreakerConfig; use JOOservices\Client\Resilience\RetryConfig; $client = ClientBuilder::create() ->withRetry(new RetryConfig( onRetry: fn (int $attempt, int $delayMs, $reason, $request) => logger()->warning('retrying', [ 'attempt' => $attempt, 'delay_ms' => $delayMs, ]) )) ->withCircuitBreaker(new CircuitBreakerConfig( onCircuitOpen: fn (string $partitionKey) => alert('circuit open: ' . $partitionKey), onCircuitClose: fn (string $partitionKey) => alert('circuit closed: ' . $partitionKey), )) // Recover: return a fallback ResponseInterface instead of failing. ->onError(fn (Throwable|Psr\Http\Message\ResponseInterface $outcome, array $options) => $outcome instanceof Throwable ? new \GuzzleHttp\Psr7\Response(503, [], 'fallback') : $outcome) ->build();
Scope the circuit breaker per tenant with CircuitBreakerConfig(respectPerRequestPartitionKey: true)
and pass partition_key per request. The rate limiter automatically honours server
RateLimit-Reset (epoch) and Retry-After (429/503) headers; disable with
RateLimitConfig(honorServerHeaders: false).
Progress callbacks
$client = ClientBuilder::create() ->withProgress( download: fn (int $total, int $downloaded) => updateProgressBar($total, $downloaded), upload: fn (int $total, int $uploaded) => updateProgressBar($total, $uploaded), ) ->build(); $client->download('https://example.com/big.zip', '/tmp/big.zip');
Header merge policy
withHeaders($headers, overwrite: true) (default) lets incoming values win over
previously set headers; overwrite: false keeps previously set values (defaults-first).
Per-request headers always win over builder-level headers.
Logging & Caching
The client decides when and what to log (LoggingMiddleware + LogSanitizer). Inject where via any PSR-3 logger:
use JOOservices\Client\Cache\FilesystemCache; use JOOservices\Client\Support\CachedExternalWanIpProvider; use Psr\Log\LoggerInterface; /** @var LoggerInterface $logger */ $cache = new FilesystemCache(__DIR__ . '/cache'); $client = ClientBuilder::create() ->withLogger($logger, logBodies: false) ->withWanIpProvider(new CachedExternalWanIpProvider()) // opt-in WAN IP in logs ->withCache($cache, defaultTtl: 3600) ->build();
Request and response body logging should stay opt-in. Keep logBodies: false unless the integration explicitly needs body-level diagnostics and the payload is safe to record.
WAN/public IP enrichment is also opt-in. It may be personal or infrastructure-sensitive data; enable it only where collection, retention, and access to the resulting log context are appropriate for your deployment.
3.0 migration notes
- Mongo/MySQL package loggers and
withDefaultLogging()/MonologFactoryare removed. Wire your ownLoggerInterface— see UPGRADE-3.0.md. - Historical 2.x notes: UPGRADE-2.0.md, UPGRADE-2.3.md.
- WAN IP and body logging remain opt-in.
Quality Assurance
The repository uses the DTO-style quality contract with a few client-specific additions.
composer check
Run composer lint:all and composer test directly when you want the underlying steps separately; use composer check for the standard combined gate.
Additional validation commands:
composer lint:fixcomposer test:coveragecomposer benchcomposer ci
Intentional client-specific differences from the DTO baseline:
- 98% coverage gate on
composer test:coverage - dedicated benchmark job with PHPBench
- optional live-network job for real external IP logging checks
- secret scanning in CI is
Security / Scan - secret(Gitleaks inside the shared Security job)
Repository-standard auxiliary automation now also matches DTO more closely:
- semantic PR titles require an uppercase subject
- pull requests are auto-labeled with DTO-style label categories
- releases validate tags before publishing GitHub releases and can notify Packagist when credentials are configured
Coverage remains an intentional client-specific divergence: this repo keeps a 98% gate and a narrower excluded-source set so the enforced threshold stays meaningful for the exercised client runtime surface.
AI Development Workflow
This package includes AI-oriented scaffolding to keep delivery consistent with quality gates.
- Agent guidance: AGENTS.md, CLAUDE.md
- Tooling folders:
.claude/commands,.cursor/rules,ai/skills,antigravity/prompts,jetbrains/prompts - Development process references: docs/04-development, docs/05-maintenance
When AI changes code, run:
composer check
Docker Development
If PHP is not installed locally, run everything in Docker.
docker compose up -d --build php
docker compose run --rm php composer install
docker compose run --rm php composer test
For live network integration tests (real sites), run:
docker compose run --rm -e JOOCLIENT_RUN_LIVE_NETWORK_TESTS=1 php \
vendor/bin/phpunit tests/Feature/Logging/RealSiteIpLoggingTest.php
This test hits:
https://httpbin.org/gethttps://example.comhttps://google.com
Contributing
See CONTRIBUTING.md for details.
Normal feature and fix work branches from develop and PRs back into develop. Release preparation uses release/<version> branches from develop into master.