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
- 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
- mongodb/mongodb: ^2.0
- 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()
- ext-mongodb: Required to persist logs with MongoDbLogger (PECL mongodb)
- ext-pdo: Required to persist logs with MySqlLogger
- ext-pdo_mysql: Required for MySqlLogger MySQL/MariaDB DSNs
- jooservices/dto: Optional DTO helpers for ResponseWrapper::toDto()
- mongodb/mongodb: Required to persist logs with MongoDbLogger (native MongoDB PHP library)
This package is auto-updated.
Last update: 2026-08-09 22:51:04 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: Guzzle (default) and native cURL transports 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 7.10 / 8: Native Guzzle transport with stable package exceptions.
- Testing: Builder-native fakes with 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 Monolog. MongoDB logging optionally requires mongodb/mongodb (^2.0) and ext-mongodb in the consuming application. MySQL logging uses PDO (ext-pdo / ext-pdo_mysql for MySQL DSNs).
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();
| 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.
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.
Structured request logging: choose MongoDB or MySQL
The package supports one structured backend per builder — MongoDB or MySQL — not both. Mixing withMongo* and withMySql* on the same builder throws InvalidConfigurationException. Use an env/config knob to select which helper to call.
| Backend | App Composer | Extension | Schema |
|---|---|---|---|
| MongoDB | mongodb/mongodb:^2.0 |
ext-mongodb |
Collection (indexes optional) |
| MySQL / MariaDB | none (PDO) | ext-pdo + ext-pdo_mysql |
docs/03-examples/sql/client_request_logs.sql |
$builder = ClientBuilder::create()->withBaseUri(getenv('API_BASE_URI')); $backend = getenv('CLIENT_LOG_BACKEND') ?: 'none'; // mongo | mysql | none match ($backend) { 'mongo' => $builder->withMongoLoggingConfig(new \JOOservices\Client\Logging\MongoDbLogConfig( getenv('MONGODB_URI'), getenv('MONGODB_DATABASE'), )), 'mysql' => $builder->withMySqlTableLogging($pdo), default => $builder, }; $client = $builder->build();
Rebuild the client after switching backends. Same-backend helpers may be called twice (last wins). withLogger() clears the structured claim so you can install a generic PSR-3 logger.
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.
Declare mongodb/mongodb and enable ext-mongodb in your application when using these helpers.
MySQL logging
use JOOservices\Client\Logging\MySqlLogConfig; use PDO; $pdo = new PDO(getenv('MYSQL_DSN'), getenv('MYSQL_USER'), getenv('MYSQL_PASSWORD')); $client = ClientBuilder::create()->withMySqlTableLogging($pdo)->build(); $client = ClientBuilder::create() ->withMySqlLoggingConfig(new MySqlLogConfig(getenv('MYSQL_DSN'), username: getenv('MYSQL_USER'), password: getenv('MYSQL_PASSWORD'))) ->build();
Apply the example DDL in docs/03-examples/sql/client_request_logs.sql (or an equivalent schema) before writing rows.
Do not stack MySQL helpers with Mongo helpers on the same builder.
2.0 / 2.3 migration notes
- Guzzle 7.10+ or 8 is required. Laravel MongoDB and
ClientRequestLogare removed. - Mongo logging is an optional soft dependency: see UPGRADE-2.3.md if you use Mongo helpers. Review UPGRADE-2.0.md for the 2.0 native-driver cutover.
- 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.