Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Http Client Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Client.php — the PSR-18 entry point (sendRequest()).
  2. The adapter directory — cURL, stream, and HAR replay transports. This tells you what configuration knobs exist.
  3. Session.php — cookie jar + request history, the thing you will actually reach for in real apps.
  4. 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.

Implementation Patterns

Wrap the client in a typed service

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).

Stateful sessions for cookie-bearing endpoints

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.

Pick the adapter deliberately

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.

Redirects and retries are built in — configure, don't reimplement

The package handles:

  • Follow-redirect, method-aware for 307/308 (a POST that gets a 308 stays a POST; a 301/302 downgrades to GET per RFC). Don't hand-roll redirect logic on top.
  • Retry with backoff for transient failures.

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.

Testing with HAR replay

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.

Keep request building in one place

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?"

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings