swlib/saber
Saber is a modern HTTP client for PHP with a clean, chainable API. It streamlines API requests by handling headers, query parameters, JSON bodies, authentication, and response parsing in readable code, so integrations stay concise and maintainable.
Saber is a Swoole-coroutine HTTP client that gives you a Guzzle-ish, PSR-7-based API without the blocking I/O. Because it depends on Swoole's coroutine scheduler, it only works when executed inside a coroutine context — a Swoole\Coroutine\run() block, a Swoole HTTP/TCP worker, or any code already running under Co\run().
Minimum setup:
composer require swlib/saber
# requires ext-swoole (4.4+ recommended) with coroutine support
Your first request:
use Saber\Saber;
Co\run(function () {
$response = Saber::get('https://httpbin.org/get');
echo $response->getStatusCode(); // int
echo $response->getBody()->getContents(); // PSR-7 stream
});
Where to look first:
README.md for the canonical Saber::get/post/... + Saber::wait() examples.src/Saber.php (the client) and src/Coroutine.php (the concurrency plumbing) — these define the public surface you will actually call.src/Adapter/...) — the Swoole adapter is what turns PSR-7 requests into Swoole\Coroutine\Http\Client calls; a second adapter exists for testing/fallback.examples/ and tests/ for working snippets of concurrency, interceptors, and exception handling.First real use case: fanning out to several internal microservices (or third-party APIs) from a single Swoole worker without blocking — replace a chain of file_get_contents() / blocking Guzzle calls with concurrent Saber requests. Start with one request to confirm the coroutine wiring (you'll get a hard error if you're not in a coroutine), then move to the concurrent pattern below.
Single request inside a worker/controller. Saber calls yield the coroutine, so they compose naturally with other Swoole I/O (DB, Redis, filesystem). This is the drop-in replacement for blocking HTTP:
Co\run(function () {
$resp = Saber::get('https://api.internal/users/42', [
'timeout' => 2,
'connect_timeout' => 0.5,
'headers' => ['Authorization' => 'Bearer ' . $token],
]);
if ($resp->getStatusCode() !== 200) { /* ... */ }
$data = json_decode((string) $resp->getBody(), true);
});
Configured client instance. For anything non-trivial, create a client with a base URI and shared defaults, rather than passing options on every call:
$api = Saber::create([
'base_uri' => 'https://api.internal',
'timeout' => 3,
'headers' => ['X-Service' => 'billing'],
]);
$users = $api->get('/users/42');
$orders = $api->get('/orders?user=42');
Concurrent fan-out — the pattern you'll use most. The idiomatic Saber concurrency shape is: issue several requests on the same client, then call Saber::wait() to execute them together in one coroutine wave:
Co\run(function () {
$saber = Saber::create(['base_uri' => 'https://httpbin.org']);
$saber->get('/get');
$saber->get('/delay/1');
$saber->get('/uuid');
$responses = Saber::wait(); // array of PSR-7 responses
foreach ($responses as $response) {
echo $response->getStatusCode(), PHP_EOL;
}
});
Use this for API aggregation, dashboards, or "fetch N resources for one page render" endpoints — it's where Saber pays for itself versus sequential Guzzle calls.
Middleware / interceptors for cross-cutting concerns. Attach auth headers, tracing IDs, retry logic, and logging through interceptors so route code stays clean:
$saber = Saber::create(['base_uri' => 'https://api.internal']);
$saber->intercept(function ($request, callable $next) {
$request = $request->withHeader('X-Request-Id', bin2hex(random_bytes(8)));
return $next($request);
});
Interceptors are also the cleanest place to implement retries (inspect status/exception, sleep with Co::sleep(), and call $next() again) and JSON decoding, so callers get typed results.
Integration with PSR-7/PSR-18-style code. Responses are PSR-7 ResponseInterface objects, so (string) $response->getBody(), $response->getHeaderLine() etc. work as usual, and you can hand responses to existing middleware, loggers, or serializer code.
Retry + backoff helper. Since you're inside a coroutine, Co::sleep() gives non-blocking backoff:
$saber->intercept(function ($request, callable $next) {
$attempt = 0;
beginning:
try {
$response = $next($request);
if ($response->getStatusCode() >= 500 && $attempt < 3) { throw new \RuntimeException('retry'); }
return $response;
} catch (\Throwable $e) {
if (++$attempt >= 3) throw $e;
\Swoole\Coroutine::sleep(0.2 * $attempt);
goto beginning; // or a loop
}
});
(Write that with a while loop in real code; the point is that the backoff doesn't block other coroutines.)
Testing/CI. Because Saber needs a coroutine runtime, wrap tests in Co\run(), or configure the non-Swoole adapter if you truly need to run without coroutine support (it degrades to synchronous behavior — useful for a smoke test but defeats the purpose).
Saber::get/post/... call, and Saber::wait(), must be inside a coroutine. In a plain FPM process it will fail; you need a Swoole server worker or Co\run().Saber::wait() is a global-ish batching step, not a generic promise. The batching is per-client/coroutine; mixing clients, or calling wait() from a different coroutine than the one that enqueued the requests, won't do what you expect. Keep the "enqueue several, then wait" block together.$response->getBody() returns a PSR-7 stream. If you echo it, cast to (string) or call ->getContents()`. Reading a stream twice returns empty — cache the decoded value if you need it more than once.Swoole\Coroutine\Http\Client directly with a write handler, or stream to disk at the socket level.timeout and connect_timeout govern a single HTTP exchange. If you add redirects, retries, and DNS retries, the wall-clock time can be multiples of timeout. Wrap the whole logical operation in Swoole\Coroutine::withTimeout() if you need a hard deadline.Channel-based semaphore) — otherwise you can exhaust file descriptors or the target's rate limit.allow_redirects options on the client if you need session behavior. Many "why am I getting 302 with an empty body" bugs are just redirects not being followed.ssl_cert/verification and proxy settings against the Swoole version — different Swoole minors handle SNI differently. Test against a real HTTPS endpoint, not just localhost.composer.json, and treat it as a known-good dependency rather than one you upgrade freely. For new projects in 2024+, also evaluate Hyperf's Guzzle coroutine adapter, Symfony HttpClient with a coroutine-based dispatcher, or raw Swoole\Coroutine\Http\Client — Saber's ergonomics (PSR-7 + interceptors + wait()) are still nice, but you should consciously accept the maintenance risk.exception callable option lets you centralize failure handling. Reuse configured client instances per service rather than recreating them per request — that keeps default headers and connection state consistent.How can I help you explore Laravel packages today?