react/http
Event-driven, streaming HTTP client and server for ReactPHP. Send concurrent async HTTP/HTTPS requests and build plaintext or secure servers that handle many requests without blocking.
react/http is distributed as react/http and requires an event loop from react/event-loop (plus react/socket and react/promise, pulled in transitively). The install is trivial:
composer require react/http
Two entry points account for almost everything you'll do:
React\Http\Browser — the async HTTP client. Each verb (get(), post(), put(), patch(), delete(), head()) returns a React\Promise\PromiseInterface that fulfils with a PSR-7 ResponseInterface or rejects with an Exception.React\Http\HttpServer — a request handler wrapped around a React\Socket\SocketServer, producing PSR-7 ServerRequestInterface and expecting a ResponseInterface (or a promise for one).The first five minutes should be: instantiate a Browser, fire two GETs, and confirm they interleave rather than run sequentially. That is the whole payoff of the library — non-blocking I/O with a familiar PSR-7 surface.
$browser = new React\Http\Browser();
$browser->get('https://example.com/a')->then(fn ($r) => print $r->getBody());
$browser->get('https://example.com/b')->then(fn ($r) => print $r->getBody());
For the server side, mirror the README quickstart: an HttpServer closure returning Response::plaintext() or Response::json(), handed to SocketServer::listen(). Read the examples/ directory next — it is the real documentation for streaming, proxies, and middleware. Then read the API sections for Browser::with*() and React\Http\Middleware\*, because those two groups define the library's actual workflow model.
A realistic first production use case is not replacing your web framework. It is a fan-out worker: an Artisan command or long-running daemon that fires concurrent calls at an internal API, a webhook dispatcher, or a health-check probe. That is where the async model pays for itself immediately and the blast radius is small.
Treat Browser as an immutable, pre-configured service. Every withTimeout(), withBase(), withFollowRedirects(), withRejectErrorResponse(), withHeader(), and withResponseBuffer() returns a clone. The idiomatic pattern is to build one configured instance at container-registration time — base URI, auth header, timeout, redirect policy — and inject it read-only wherever needed:
$browser = (new Browser(new Connector(['timeout' => 5])))
->withBase('https://api.internal/v1/')
->withTimeout(10.0)
->withHeader('Authorization', 'Bearer ' . $token)
->withFollowRedirects(false);
Bind this as a singleton in your service container; do not mutate it per request.
Concurrency is handled by promises, not by you. Use React\Promise\all() when every call must succeed and React\Promise\allSettled() (or per-promise then(null, $onError)) when partial failure is acceptable. For hundreds of simultaneous requests, cap in-flight work yourself — the client will happily open them all and exhaust file descriptors.
Streaming is the escape hatch for anything large or unbounded. The default get()/post() keeps the entire response body in memory. Switch to requestStreaming() when any of these hold: >100 concurrent requests, expect >1 MiB bodies, or you want to process chunks as they arrive. Attach data, end, and error handlers or pipe() into a filesystem stream:
$browser->requestStreaming('GET', $url)->then(function (ResponseInterface $response) use ($sink) {
$response->getBody()->pipe($sink);
});
The same holds symmetrically for uploads: pass a ReadableStreamInterface as the body to post()/put() and it will use Transfer-Encoding: chunked unless you supply an explicit Content-Length.
Server handlers should return promises for anything deferred. A handler may return a ResponseInterface synchronously or a promise that resolves to one. This is how you await a database or downstream API without blocking the loop. Wrap the whole handler chain in a try/catch + rejection handler so no exception escapes as an unhandled rejection (which silently kills the connection).
Middleware is a plain callable chain. Signature: function (ServerRequestInterface $request, callable $next): ResponseInterface|PromiseInterface. Use it for routing, CORS, request IDs, auth, and structured access logging — the same mental model as Laravel middleware, minus container magic. Ship-order matters: StreamingRequestMiddleware must come first if you want streaming bodies; RequestBodyBufferMiddleware + RequestBodyParserMiddleware must run before any handler that reads parsed JSON or form data, because buffering consumes the stream.
Reach for the bundled middleware rather than hand-rolling: LimitConcurrentRequestsMiddleware (backpressure for expensive handlers), RequestBodyBufferMiddleware (with a size limit — set one, the default is unlimited), and RequestBodyParserMiddleware (JSON/form decoding into getParsedBody()).
Use the transport options for infrastructure concerns. Browser accepts a custom Connector, which is the extension point for SOCKS/SSH proxies, Unix domain sockets, DNS behaviour, and connection timeouts. For servers, SocketServer handles TLS via stream context — but do that behind nginx in practice.
Where it fits in a Laravel codebase: put it in queued jobs, Artisan daemons (via supervisor), or a standalone service. Do not try to run the event loop inside an FPM request — Laravel's kernel is synchronous and you would be building a second runtime inside the first.
with*() returns a new object. $browser->withTimeout(5); without reassignment silently does nothing. This is the single most common first-day bug.default_socket_timeout is the default timeout (60s) and covers connect + send + headers + full buffered body + redirects. Streaming request/response bodies are excluded from it — so a stalled stream can hang indefinitely. Always set an explicit withTimeout() and add your own timer or cancellation for streaming paths.$promise->cancel() rejects with an exception and tears down the connection. Use it on request-abort or when a fan-out has already lost its race.sleep(), synchronous PDO/Redis, file_get_contents(), CPU-heavy transforms — all of them stall the entire loop for every concurrent connection. Use async drivers (clue/reactphp-redis, reactphp/mysql) or push work out to a queue.withResponseBuffer() to a sane cap so a hostile or misconfigured endpoint can't OOM your worker; combined with requestStreaming(), you get both safety and throughput.Response::plaintext(...)->withStatus(500).master tracks an unreleased v3 API while 1.x is the stable release. Pin ^1.0 unless you are deliberately tracking development, and re-read the changelog before bumping — the message/Browser surface has shifted between lines.Content-Length for known-size bodies; omit it and the server falls back to chunked/connection-close semantics. A missing length on a keep-alive connection is a classic source of hung clients.Authorization is stripped on cross-host redirects by default — good for security, surprising if you rely on redirects to an authenticated CDN. Set withFollowRedirects(false) and follow manually when you need control.Loop::run() must be called exactly once at the top level — nested Loop::run() calls throw. In tests, drive the loop explicitly or use React\Async\await() to make assertions on resolved values.Connector (proxies, sockets, DNS), custom loop instance (for deterministic tests with a fake clock), middleware (all cross-cutting behaviour), and ResponseException + withRejectErrorResponse() for turning non-2xx into rejections instead of fulfilled responses you must inspect.How can I help you explore Laravel packages today?