berlioz/http-client
A lightweight, expressive HTTP client for Laravel and PHP that simplifies API integrations with a fluent request builder, JSON handling, headers, query params, retries, and testable fake responses. Ideal for clean, readable service-to-service calls.
Install via Composer (the package lives in the Berlioz monorepo, so it resolves through Packagist as berlioz/http-client):
composer require berlioz/http-client
It is a PSR-18 client, so it does not ship its own request/response classes — pair it with a PSR-17 factory. The framework's own berlioz/http-message (HttpFactory) implements all six PSR-17 factories and is the natural companion:
composer require berlioz/http-message
First place to look: src/Http/Client/ in the monorepo. Read, in this order:
Client.php — the PSR-18 entry point (sendRequest()).cURL, stream, and HAR replay transports. This tells you what configuration knobs exist.Session.php — cookie jar + request history, the thing you will actually reach for in real apps.tests/ — the monorepo mirrors src/ under tests/, and for a 1-star package the tests are more reliable documentation than the README.First use case — call a JSON API:
use Berlioz\Http\Client\Client;
use Berlioz\Http\Message\HttpFactory;
$factory = new HttpFactory();
$client = new Client($factory);
$request = $factory->createRequest('GET', 'https://api.example.com/v1/status')
->withHeader('Accept', 'application/json');
$response = $client->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new RuntimeException('API returned ' . $response->getStatusCode());
}
$data = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
That is the whole happy path. Everything below is about the day-two problems: cookies, retries, redirects, and testing.
Because it is PSR-18, code against Psr\Http\Client\ClientInterface, not the concrete class. Register it once in the Berlioz container and inject it:
use Berlioz\Http\Client\Client;
use Berlioz\Http\Message\HttpFactory;
use Psr\Http\Client\ClientInterface;
// service provider / bootstrap
$container->set(ClientInterface::class, fn () => new Client(new HttpFactory()));
Callers then type-hint ClientInterface. This is also what makes the HAR-replay swap in tests trivial (see below).
Plain sendRequest() calls are stateless — every request starts with an empty cookie jar. For login flows, CSRF-token handshakes, or anything that sets Set-Cookie, keep a Session alive across requests:
$session = new Session($client);
$session->sendRequest($factory->createRequest('POST', 'https://app.example.com/login')
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
->withBody($factory->createStream('user=me&pass=secret')));
// Cookies from the login response are now replayed automatically
$dashboard = $session->sendRequest(
$factory->createRequest('GET', 'https://app.example.com/dashboard')
);
The session also keeps request history, which is invaluable when a chain of five calls fails on the fourth — dump the history instead of adding var_dump calls one at a time.
The adapter is the transport. Three implementations ship:
| Adapter | Use when |
|---|---|
| cURL | Production. Handles HTTP/2, connection reuse, fine-grained timeouts. |
| Stream | cURL extension unavailable, or a constrained/embedded environment. |
| HAR replay | Tests. Replays a recorded .har file with no network. |
Construct the client with the adapter you want, and make it configurable per environment (e.g. read from Berlioz config: {config: http.client.adapter}). Do not hardcode cURL in application code — the HAR adapter exists precisely so integration tests do not hit the network.
The package handles:
Enable retries only for idempotent verbs. A retried POST /orders creates two orders. If you need retries on POST, guard them at the application layer with an idempotency key header, not by bumping the retry count.
$client = new Client($factory, adapter: $adapter, retry: 3);
Set a per-request timeout rather than relying on PHP's max_execution_time — a hung API call should fail at 5s, not at 30s with a fatal error.
Record one successful exchange per endpoint, commit the .har under tests/fixtures/, and swap the adapter in the test bootstrap:
// tests/bootstrap.php
$container->set(ClientInterface::class, fn () => new Client(
new HttpFactory(),
adapter: new HarAdapter(__DIR__ . '/fixtures/api.har'),
));
This gives you deterministic tests with zero mocking of ClientInterface and zero flakiness from the upstream API. Recorded HARs are also a readable spec of what your code actually sends — headers, body shape, retry behavior.
PSR-7 requests are immutable, so withHeader() chains multiply. Wrap endpoint calls in small methods on a gateway class:
final class BillingGateway
{
public function __construct(private ClientInterface $client, private HttpFactory $factory) {}
public function charge(string $customerId, int $cents): array
{
$response = $this->client->sendRequest(
$this->factory->createRequest('POST', 'https://billing.example.com/charges')
->withHeader('Content-Type', 'application/json')
->withHeader('Idempotency-Key', bin2hex(random_bytes(16)))
->withBody($this->factory->createStream(json_encode([
'customer' => $customerId,
'amount' => $cents,
], JSON_THROW_ON_ERROR)))
);
// PSR-18 does NOT throw on 4xx/5xx — check explicitly
if ($response->getStatusCode() >= 400) {
throw new BillingException($response->getStatusCode(), (string) $response->getBody());
}
return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
}
}
This is the single most valuable pattern: every sendRequest() call site should be inside a method that has already answered "what does failure mean here?"
PSR-18 only throws on transport failures. sendRequest() throws ClientExceptionInterface for DNS failures, connection refused, timeouts, and malformed responses. It returns a ResponseInterface for 404, 500, 503 — those are not exceptions. Every call site needs an explicit status check. This is the number-one source of silently swallowed errors when people migrate from Guzzle's http_errors default.
The response body is a stream, and it has a cursor. json_decode((string) $body) works because __toString() rewinds. But if you ->read() it first and then cast, you get ''. Either cast immediately, or call $body->rewind() before re-reading. Never pass a body stream to two consumers without rewinding.
Cookie state lives on Session, not on Client. Constructing a fresh Session per request throws away cookies. Hold one Session per "user session" and reuse it. Conversely, share a Session between tenants and you will leak cookies across users.
Verify the constructor before copying examples. This package has one star and one release; the exact signature of Client, Session, and the adapter classes may differ from what any guide (including this one) shows. Read Client.php and the constructor-promoted properties first. The concepts above are stable; the parameter names may not be.
Retry plus non-idempotent verbs equals duplicate side effects. As above — only retry GET/HEAD/PUT/DELETE by default, and gate POST behind an idempotency key.
Redirects can strip auth headers. Cross-host redirects should drop Authorization, and well-behaved clients do. If you are hitting an API that redirects to a CDN, do not assume your bearer token survived. Check the final Host before trusting a response.
HAR files contain secrets. Recording a HAR from a real session captures Cookie, Authorization, and sometimes request bodies. Scrub before committing, or record against a sandbox with throwaway credentials. Add a CI check for Authorization: inside tests/fixtures/*.har.
Stream adapter behaves differently from cURL. No connection reuse, different timeout semantics, and some TLS options are unavailable. If production uses cURL, your test adapter should also be cURL (or HAR), not stream — otherwise you are testing a different transport.
PHP >= 8.2, declare(strict_types=1) everywhere. If you contribute back, the monorepo requires the license block header, PSR-12, 4-space indent, 120-char lines, and one class per file. Commits follow Conventional Commits with the package as scope: fix(http:client): ....
Extension point: write your own adapter. The adapter pattern is the intended extension seam. If you need signing (AWS SigV4), mTLS client certs, or a proxy with custom auth, implement the adapter interface rather than decorating ClientInterface — decorating loses access to connection-level configuration.
Monorepo, not a standalone repo. BerliozFramework/http-client is a read-only subsplit mirror of src/Http/Client/ in BerliozFramework/Berlioz. Issues and PRs belong in the monorepo; fixing a bug in the mirror will be overwritten on the next sync.
Debugging a failed chain: the Session history is the fastest path. Dump every request/response pair with method, URI, status, and timing before reaching for a proxy — by the time you have added logging to five call sites you have already lost the original request.
Check the CHANGELOG before upgrading. The package tracks framework minor versions; the monorepo publishes CHANGELOG.md per package, and breaking changes are marked with ! in commit subjects. With one dependent, breakage will not be caught by the ecosystem — pin the version and read the diff.
How can I help you explore Laravel packages today?