jooservices / client
Strict, extensible PHP 8.5+ HTTP client wrapper for JOOservices
Requires
- php: ^8.5
- guzzlehttp/guzzle: ^8.0
- mongodb/mongodb: ^2.0
- monolog/monolog: ^3.10
- 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-mongodb: Required to persist logs with MongoDbLogger (PECL mongodb); mongodb/mongodb hard-requires it at composer install time
- jooservices/dto: Optional DTO helpers for ResponseWrapper::toDto()
This package is auto-updated.
Last update: 2026-07-21 22:06:30 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.
Features
- Strictly Typed: Configuration object (
ClientConfig) ensures type safety before requests start. - Layered Architecture: Adapters (Guzzle) are isolated from Core Logic.
- Resilience: Built-in Retry (Backoff/Jitter), Circuit Breaker, Rate Limit, Bulkhead, Fallback, and Deadline middleware.
- Observability: Logging, W3C trace context, metrics, and correlation IDs.
- Auth: Bearer, API key, Basic auth, and OAuth token refresh middleware.
- Performance: < 10μs overhead per request.
- Guzzle 8: Native Guzzle 8 transport with stable package exceptions.
- Testing: Builder-native fakes with deterministic retry and rate-limit sleeps.
Installation
composer require jooservices/client
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']);
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.
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.
Logging & Caching
use JOOservices\Client\Logging\MonologFactory; use JOOservices\Client\Cache\FilesystemCache; use JOOservices\Client\Support\CachedExternalWanIpProvider; $logger = MonologFactory::createDaily('my-app', __DIR__ . '/logs'); $cache = new FilesystemCache(__DIR__ . '/cache'); $client = ClientBuilder::create() ->withLogger($logger, logBodies: true) ->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.
Mongo logging
use JOOservices\Client\Logging\MongoDbLogConfig; // Shared MongoDB\Collection (DI) $client = ClientBuilder::create()->withMongoCollectionLogging($collection)->build(); // URI + database (scripts / small apps) $client = ClientBuilder::create() ->withMongoUriLogging(getenv('MONGODB_URI'), 'my_app', 'http_client_logs') ->build(); // Fully-specified config (bounded timeouts, byte limits, schema version) $client = ClientBuilder::create() ->withMongoLoggingConfig(new MongoDbLogConfig(getenv('MONGODB_URI'), 'my_app')) ->build();
Each method is unambiguous about its source — there is no single overloaded withMongoLogging()
that silently picks between a collection, a URI, and a config. MongoDbLogger itself has no
implicit default destination: constructing it directly without a writer throws, so a
misconfigured logger fails fast at startup instead of silently writing logs nowhere.
2.0 migration notes
- Guzzle 8 and
mongodb/mongodb:^2.0are required. Laravel MongoDB andClientRequestLogare removed. ext-mongodbis an install-time requirement. Review UPGRADE-2.0.md before upgrading.- Mongo logging uses native-driver PSR-3 event documents with bounded driver timeouts.
- 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 workflow with PHPBench
- optional live-network workflow for real external IP logging checks
- active CI secret scanning via
secret-scanning.yml
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 mongodb
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.