jooservices / client
PSR-18 HTTP client with JOOservices developer experience.
Requires
- php: >=8.5
- ext-curl: *
- jooservices/dto: ^3.0
- nyholm/psr7: ^1.8
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
Requires (Dev)
- captainhook/captainhook: ^5.23
- fakerphp/faker: ^1.24
- friendsofphp/php-cs-fixer: ^3.65
- guzzlehttp/guzzle: ^7.9
- laravel/pint: ^1.18
- phpbench/phpbench: ^1.6
- phpmd/phpmd: ^2.15
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
- squizlabs/php_codesniffer: ^3.12 || ^4.0
Suggests
- guzzlehttp/guzzle: Enables GuzzleTransport.
- justinrainbow/json-schema: Enables JSON schema response validation.
- psr/log: Enables LoggingMiddleware.
- psr/simple-cache: Enables CacheMiddleware and persistent resilience stores.
Provides
None
Conflicts
None
Replaces
None
README
A PHP 8.5+ PSR-18 HTTP client with a strict standards core and batteries included: fluent request building, a ranked middleware pipeline, resilience (retry, circuit breaker, rate limit, bulkhead, fallback, deadline), hardened security defaults, response-to-DTO mapping via jooservices/dto, and deterministic test fakes.
Warning
v4.0.0 is a complete ground-up rebuild around PSR-7 / PSR-17 / PSR-18 and is NOT backward compatible with any previous version.
Client verb methods and Guzzle option bags are gone; there are no legacy shims, no deprecation bridges, and no compatibility code.
Upgrading means rewriting call sites against the new API — see UPGRADE-4.0.md and the changelog.
Upgrade highlights
- v4 is a strict PSR-18
HttpClient—sendRequest()plussend($request, $options); verb methods are removed. - Guzzle option bags are replaced by a portable, validated
RequestOptionsDTO. - Fluent immutable
RequestBuilderproduces aPreparedRequest(PSR-7 form plus options). - Redirect-chain cookies carrying a
Domainattribute stay scoped to the response host; sibling subdomains do not share these temporary cookies. Responsewrapper adds status helpers, cached JSON, download-size ceiling, opt-inthrow(), and DTO mapping.- Deterministic test fakes replace live-network dependencies.
Features
- PSR-18
HttpClientbuilt through the immutableClientBuilder; base URI treated as a directory prefix, protocol-relative URIs rejected. - Fluent
RequestBuilder: verb methods, headers, query, raw body,withJson()/withMultipart()(autoContent-Type). Response::from(): status helpers, header access, BOM-stripping cached JSON, 100 MB body ceiling,throw(),toPsrResponse()escape hatch.- Ranked middleware pipeline with canonical ordering and presets: observability, resilience, auth/security, and DX.
- Resilience: retry, circuit breaker, rate limit, bulkhead, fallback, deadline — in-memory stores by default, PSR-16 adapters supported. A
withDeadline()budget includes request attempts and retry backoff; each attempt timeout is clamped to the remaining budget. - Security defaults: TLS verification on, CR/LF injection rejected, credential stripping and private-IP policy on redirects, download-size guard, log sanitizer.
- Transports:
CurlTransport(default),PsrTransport,GuzzleTransport(optional),FailoverTransport. - Deterministic testing: fake registry, scripted
TestResponseSequences, recorded-request assertions,InteractsWithHttpClienttrait.
Requirements
- PHP
>= 8.5 - Extension:
curl - Core dependencies:
psr/http-client,psr/http-message,psr/http-factory,nyholm/psr7,jooservices/dto ^3.0 - Optional:
guzzlehttp/guzzle(GuzzleTransport),justinrainbow/json-schema(schema validation),psr/log(logging),psr/simple-cache(cache middleware + persistent stores) - Docker (recommended — all local tooling runs in
php:8.5-cli-bookworm)
Installation
composer require jooservices/client:^4.5
Quick start
use JOOservices\Client\Client\ClientBuilder; use JOOservices\Client\Response\Response; use JOOservices\Client\Resilience\RetryConfig; use JOOservices\Client\Testing\RecordedRequest; use JOOservices\Client\Testing\TestResponse; use JOOservices\Client\Testing\TestResponseSequence; // Build once — immutable configuration, canonical middleware ranking $client = ClientBuilder::create() ->withBaseUri('https://api.example.test/v1') ->withBearerToken($token) ->withRetry(new RetryConfig(maxAttempts: 3)) ->build(); // Fluent request construction → PreparedRequest (PSR-7 form + portable options) $request = $client->requestBuilder()->post('users')->withJson($user)->build(); $upload = $client->requestBuilder()->post('media')->withMultipart([ ['name' => 'title', 'contents' => 'Photo'], ['name' => 'file', 'contents' => fopen($photoPath, 'rb'), 'filename' => 'photo.jpg', 'contentType' => 'image/jpeg'], ])->build(); // PSR-18 send; per-request options override builder defaults $psrResponse = $client->send($request->toPsr(), $request->options()); // Opt-in HTTP-status exception, then map the body to a DTO $response = Response::from($psrResponse)->throw()->toDto(UserDto::class); // Deterministic tests — no network ClientBuilder::fake(); ClientBuilder::respond( 'GET', 'users/*', (new TestResponseSequence())->push(TestResponse::json(['id' => 'u_123'])), ); ClientBuilder::assertSent( fn (RecordedRequest $record) => $record->request->getMethod() === 'GET', );
Compression
Response compression is disabled by default. Enable it on the immutable builder to advertise supported encodings and receive decoded response bodies:
$client = ClientBuilder::create() ->withCompression() ->build();
Use the compression request option to override the builder default for one request. Caller-provided Accept-Encoding headers are preserved; decoded responses no longer expose Content-Encoding or encoded-body length headers.
Redirects
Redirects are followed by default (up to 5 hops). Credentials are checked on every hop, and cross-host targets are re-checked against the public/private target policy; same-host redirects skip DNS-based target-policy resolution. Pass an array to withRedirects() (or the allowRedirects request option) to tune the chain:
$client = ClientBuilder::create() ->withRedirects([ 'max' => 5, // hop limit 'allow_private' => false, // allow redirects to private/link-local hosts 'total_timeout' => 10.0, // one budget shared by every hop 'track_redirects' => true, // expose X-Joo-Effective-Uri / X-Joo-Redirect-History 'cookies' => true, // replay cookies a server set on an earlier hop ]) ->build();
- Same-host redirects skip the DNS-based target policy, so a relative redirect never fails closed on a transient lookup or an
/etc/hosts-only host. GuzzleTransportfails closed before following a cross-host redirect whenever the policy verifies DNS addresses, because its genericsend()contract cannot pin the connection to those addresses. Cross-host redirects that do not require DNS pinning, including literal IP targets and explicitly allowed private targets, retain their existing behavior.- Sensitive headers (including
Cookie) are stripped only when the host changes or the scheme downgrades tohttp— not on a port change or anhttp→httpsupgrade. - The cookie jar is scoped to the redirect chain: a cookie set on one hop is replayed to a later hop only when its domain, path, and
Secureflag match. It is never persisted across separate requests. withDeadline($seconds)also bounds the whole redirect chain, not just each individual hop.
Design notes
sendRequest(RequestInterface)is strict PSR-18: HTTP 4xx/5xx responses are returned, not thrown. UseResponse::from($response)->throw()when status exceptions are wanted.- Each layer owns one concern:
| Layer | Responsibility |
|---|---|
ClientBuilder |
Immutable configuration; canonical middleware ranking; terminal build(): HttpClient |
HttpClient::send($request, $options) |
Default headers, base URI resolution, portable per-request options |
RequestBuilder |
Fluent request construction → PreparedRequest with PSR-7 form plus options |
Response |
Optional convenience over PSR-7; always returns the raw response via toPsrResponse() |
- Builder options are defaults, never overrides: explicit
send()/RequestBuilderoptions win per request.
Documentation
- Changelog — version history and upgrade notes
UPGRADE-4.0.md— migrating from pre-v4 APIs- Development workflows — branches, CI, releases, and repository automation
- Security policy — private vulnerability reporting
Development
All PHP tooling runs inside Docker (php:8.5-cli-bookworm via Docker Compose); Composer downloads dependencies from Packagist.
make build # build the tooling image make install # composer install in the container make shell # interactive container shell
| Command | Purpose |
|---|---|
make validate |
composer validate --strict |
make lint |
Pint, PHPCS, PHPStan, PHPMD, PHP-CS-Fixer |
make test |
PHPUnit (Arch, Unit, Integration, E2E; no coverage) |
make test-coverage |
PHPUnit with PCOV Clover coverage |
make audit |
Composer audit |
make bench |
phpbench |
make ci |
validate, audit, lint, coverage run, 85% Unit floor |
Coverage is enforced at an 85% floor by tools/coverage-enforce.php. Git hooks are opt-in: run composer hooks:install from a clone when you want commit-message, lint, and test hooks (Captainhook).
Community
- Contributing guide — setup, git workflow, commit convention, quality gates, PR rules
- Security policy — how to report vulnerabilities privately
- Code of Conduct
- Support
- Governance
License
MIT — see LICENSE.