php-http/react-adapter
ReactPHP adapter for the PHP-HTTP httplug abstraction. It lets you send asynchronous, non-blocking HTTP requests through ReactPHP's event loop using the standard httplug and PSR-7 interfaces, so existing client code keeps working unchanged.
php-http/react-adapter is a thin HTTPlug bridge to ReactPHP's react/http Browser. It gives you an Http\Client\HttpAsyncClient implementation that runs on your existing event loop, so HTTP calls no longer block a long-running ReactPHP process. It is async-only — there is no synchronous HttpClient/PSR-18 implementation.
Install it with the PSR-7 implementation of your choice:
composer require php-http/react-adapter react/event-loop react/http guzzlehttp/psr7
Wire it to the shared loop. The constructor takes the LoopInterface (recent versions also accept a pre-built React\Http\Browser as a second argument so you can configure timeouts, redirects, TLS and connectors):
<?php
require 'vendor/autoload.php';
use Http\Adapter\React\ReactAdapter;
use GuzzleHttp\Psr7\Request;
use React\EventLoop\Loop;
$adapter = new ReactAdapter(Loop::get());
$request = new Request('GET', 'https://httpbin.org/get');
$response = $adapter->sendAsyncRequest($request)->wait(); // runs the loop until settled
echo $response->getStatusCode(), "\n", (string) $response->getBody(), "\n";
That wait() is your first use case: in a plain CLI script it behaves like a normal blocking client. The moment you are inside a running loop, you switch to then() callbacks (see below).
Where to look first:
src/ReactAdapter.php — it is small; confirm how it builds or accepts the Browser and how it maps exceptions.ReactPromise) — this is where wait() and state mapping live.tests/ for the exact error/exception behaviour, then ReactPHP's own Browser docs for anything HTTP-level (timeouts, redirects, connectors).Non-blocking calls inside a running loop. Never call wait() from inside a loop callback. Attach handlers and let the loop drive:
$adapter->sendAsyncRequest($request)->then(
function (Psr\Http\Message\ResponseInterface $response) {
echo (string) $response->getBody();
},
function (\Throwable $e) {
error_log(get_class($e) . ': ' . $e->getMessage());
}
);
Loop::get()->run(); // or just return if a server/timer already runs the loop
Fan-out / concurrency. Fire all requests first, then let the loop drain them. Two common shapes:
$pending = count($urls);
$results = [];
foreach ($urls as $i => $url) {
$adapter->sendAsyncRequest(new Request('GET', $url))->then(
function ($res) use ($i, &$results, &$pending) { $results[$i] = $res; $pending--; },
function ($e) use ($i, &$results, &$pending) { $results[$i] = $e; $pending--; }
);
}
Loop::get()->run();
// $pending === 0 here, $results holds responses and exceptions in input order
If you prefer React\Promise\all(), convert the HTTPlug promise to a React one:
use Http\Promise\Promise as HttpPromise;
use React\Promise\Promise as ReactPromise;
use function React\Promise\all;
$toReact = fn (HttpPromise $p) => new ReactPromise(
fn (callable $resolve, callable $reject) => $p->then($resolve, $reject)
);
all(array_map($toReact, $promises))->then(function (array $responses) { /* ... */ });
Use PluginClient for cross-cutting concerns. Because the inner client is async, the plugin client stays async:
use Http\Client\Common\PluginClient;
use Http\Client\Common\Plugin\HeaderSetPlugin;
use Http\Client\Common\Plugin\RetryPlugin;
use Http\Client\Common\Plugin\ErrorPlugin;
$client = new PluginClient($adapter, [
new HeaderSetPlugin(['User-Agent' => 'billing-worker/1.0']),
new RetryPlugin(['retries' => 2]),
new ErrorPlugin(), // turn 4xx/5xx into HttpException
]);
$client->sendAsyncRequest($request)->then(/* ... */);
Only call sendAsyncRequest() on the PluginClient; calling sendRequest() will fail because the wrapped client is not synchronous. Check that any third-party plugin implements the async side (AsyncPlugin) before assuming it applies.
Share the loop with the rest of the app. The whole point of the package is that HTTP I/O cooperates with your react/http server, WebSocket handler, timers and DB driver. Inject one ReactAdapter (or one PluginClient around it) as a singleton in your container and always construct it with Loop::get() / the same loop instance used everywhere else.
Sync facade for legacy code. If a small part of the codebase still expects Http\Client\HttpClient, write a narrow adapter that calls sendAsyncRequest(...)->wait(), and only use it from entry points that are not already inside the loop.
Testing. php-http/mock-client implements HttpAsyncClient and returns already-fulfilled promises; use it for unit tests of your callers. For real integration tests, spin up a local React\Socket\SocketServer and reset the global loop between tests (Loop::set(new React\EventLoop\StreamSelectLoop())) so state does not leak.
wait() inside a running loop deadlocks or throws. React's loop cannot be run re-entrantly. Inside any then(), timer or server callback, use then() or convert to a React promise and React\Async\await().Http\Discovery\Psr18ClientDiscovery or libraries type-hinting Http\Client\HttpClient to accept it. Wrap it explicitly if you need a sync surface.Browser), so letting HttpAsyncClientDiscovery instantiate it is fragile. Register it manually in your container.React\Http\Browser, not in HTTPlug options. Timeouts, redirect following, TLS/CA, proxies and connectors are Browser concerns. The adapter does not interpret Guzzle-style RequestOptions. If your version's constructor only takes the loop, that is the first limitation to work around — pass or wrap a configured Browser.Browser follows redirects by default; adding RedirectPlugin on top can change method/body semantics or hide the 3xx. Pick one layer and disable the other.->withTimeout(10.0) on the Browser (or pass a configured Browser) and let the resulting rejection surface as a network exception.Browser resolves with a fully buffered PSR-7 response, and request bodies are sent as strings. Large downloads/uploads cost memory and are not streamed through this adapter. For streaming use react/http directly.Http\Client\Exception\NetworkException (or a sibling). 4xx/5xx are not exceptions unless you add ErrorPlugin — without it they are fulfilled responses. Catch Http\Client\Exception broadly plus \Throwable.Browser bound to a different loop than the adapter runs, wait() will run the wrong loop and hang. Always use Loop::get() consistently.Loop::run() yourself after the server is started; just attach then() handlers. In a script, Loop::run() (or wait()) is fine, but call it once at the top level.react/http 1.x and react/promise 2.x/3.x. Pin versions and smoke-test on PHP 8.2+; react/promise v3 changed several type signatures.HttpAsyncClient) or an async plugin in PluginClient, not a subclass — the class is small and may be final.React\Http\Browser directly is a realistic migration if the package stops receiving updates; keep your HTTP calls behind one small interface so that swap is cheap.How can I help you explore Laravel packages today?