innmind/http-transport
Innmind HTTP Transport is a PHP library for sending HTTP requests and handling responses through a clean, framework-agnostic API. Designed for the InnMind ecosystem, it keeps transport integration simple, testable, and lightweight for applications and services.
innmind/http-transport is not a Laravel package and has no Laravel integration — no service provider, no facade, no config file, no Http::fake() equivalent. It is a low-level HTTP transport abstraction from the Innmind ecosystem: one interface, a handful of backends, and a set of small decorators you compose yourself. Treat it as an alternative to Guzzle's handler stack rather than an alternative to Laravel's Http client.
composer require innmind/http-transport
Requirements are strict (recent PHP 8.x, plus innmind/http, innmind/url, innmind/immutable). Check composer.json before promising it for a legacy app — it will not install on PHP 8.0/8.1 era projects.
Where to look first (in this order):
src/Transport.php — the whole contract is essentially one method: __invoke(Request $request): Response (older majors: transport()). Everything else is decoration.src/Curl.php, src/Stream.php — the effectful backends. Pick Curl in normal apps; Stream only when the curl extension is unavailable.src/Exception/ — connection failures and (via a decorator) HTTP error types.innmind/http package — Request, Response, Headers, Method value objects. This is where you'll spend most of your reading time, because constructing a request by hand is verbose.First use case — a queued job or artisan command hitting a third-party API where you want immutable messages, explicit retry composition, and a trivially fakeable seam:
use Innmind\HttpTransport\Curl;
use Innmind\Http\Message\Request\Request;
use Innmind\Http\Message\Method\Method;
use Innmind\Url\Url;
$transport = Curl::of(/* response factory, where the major requires one */);
$response = $transport(new Request(
new Method('GET'),
Url::of('https://api.example.com/users/42'),
));
$status = $response->statusCode()->toInt();
$body = $response->body()->toString();
Verify the exact constructor argument order against src/ the first time — the Innmind value objects are positional and unforgiving.
1. Bind it behind your own interface, not raw. Don't inject Transport into twenty classes. Wrap it in a small app-level client so the Innmind types stay at the boundary:
final class BillingApi
{
public function __construct(private Transport $transport) {}
/** @return array<string, mixed> */
public function invoice(string $id): array
{
$response = ($this->transport)($this->requestFor($id));
return json_decode($response->body()->toString(), true, flags: JSON_THROW_ON_ERROR);
}
}
Register it in a service provider with scoped() rather than singleton() (see Gotchas):
$this->app->scoped(BillingApi::class, fn () => new BillingApi(
new ThrowOnError(new Retry(Curl::of(/* ... */), 3)),
));
2. Decorator order is the whole design. Retry only retries what it can see. If you want retries on 5xx, the error-mapping decorator must sit inside the retry:
// retries connection failures AND 5xx responses
new ThrowOnError(new Retry(new Curl(/* ... */), 3))
// retries connection failures only; 5xx throws after the first attempt
new Retry(new ThrowOnError(new Curl(/* ... */)), 3)
This is the single most common source of "why didn't it retry?" confusion, and it's invisible if you only read the config.
3. Testing with a hand-rolled fake. Because the contract is a single method, you don't need a mocking library or a Http::fake() analogue. Write this once per project:
final class FakeTransport implements Transport
{
/** @var list<Request> */
public array $requests = [];
public function __construct(private Response $response) {}
public function __invoke(Request $request): Response
{
$this->requests[] = $request;
return $this->response;
}
}
For per-call responses (or to simulate a 500 then a 200), swap the single Response for a queue. Testing retry logic against a fake transport is much cleaner than asserting on Guzzle's history middleware.
4. Cross-cutting concerns as decorators. The pattern the package is built for: metrics, logging, tracing, request signing, and caching are all Transport implementations wrapping the real one.
final class Timed implements Transport
{
public function __construct(
private Transport $inner,
private LoggerInterface $logger,
) {}
public function __invoke(Request $request): Response
{
$start = hrtime(true);
try {
return ($this->inner)($request);
} finally {
$this->logger->info('outbound http', [
'method' => $request->method()->toString(),
'url' => $request->url()->toString(),
'ms' => (hrtime(true) - $start) / 1e6,
]);
}
}
}
5. Bridging to/from Laravel. Recent innmind/http messages implement the PSR-7 interfaces, so the escape hatch is mechanical: convert to PSR-7 and hand it to Illuminate\Http\Client\Response::fromPsrResponse() (or the reverse for feeding Laravel test fixtures into your fake). Keep that conversion in one adapter class — do not sprinkle new Request(...) construction across jobs.
6. Queue interplay. Let the queue own job-level retries ($tries, backoff()) and let the transport own connection-level retries, but cap the total. Three transport retries inside a job retried five times is 15 outbound calls to an API that's already down.
Pin exact versions. Innmind ships frequent majors in lockstep with innmind/http; changes that look minor (constructor arity, __invoke vs transport(), exception class names) land as breaking releases. Use "innmind/http-transport": "X.Y.Z" and read the changelog before any bump. The metadata listed here (a single star, zero dependents, a release dated in the future) means there is no community safety net: the source is small, so read src/ rather than searching for answers.
Don't register the transport as a singleton under Octane. Stateful decorators (circuit breakers, retry counters, rate limiters) and any curl handle held by the backend will leak across requests. scoped() per request, or a factory binding, keeps things honest.
The error model is not Laravel's. Connection failures throw; HTTP 4xx/5xx come back as ordinary Response values unless you add ThrowOnError (or equivalent). Catch specific Innmind\HttpTransport\Exception\* types, never bare \Exception, and always read the body off an error response before it's closed.
Bodies are streams. body()->toString() is a one-shot read in practice; if you call it twice, or after the response goes out of scope, you get nothing useful. Decode once and pass arrays around.
Headers are a strict collection of Header/Value objects, not an associative array. new Header('Accept', new Value('application/json')), and malformed names/values throw at construction. This is fine but adds boilerplate versus Http::withHeaders([...]).
No concurrency pool. Where Laravel gives you Http::pool(), this package gives you sequential calls. For fan-out over 20 endpoints, use Guzzle/Http::pool() or dispatch parallel jobs — don't try to compensate with retries.
Redirects and cookies are explicit. Verify whether your chosen backend follows redirects by default and whether a redirect decorator exists in your major; don't assume browser-like behaviour.
Debugging tip: the fastest diagnostic is a Timed-style decorator placed outside everything else, plus one that dumps $request->method(), $request->url(), and the response status on both success and exception paths. There is no built-in debug mode or log driver to enable.
Extension points are the point. Implement Transport for caching, metrics, or fakes; keep decorators stateless; keep exactly one effectful transport at the bottom of the stack. If you follow that rule the package behaves predictably — and if you don't, debugging a composed stack is considerably harder than debugging Guzzle middleware, because there's no profiler or history to inspect.
How can I help you explore Laravel packages today?