sabre/http
sabre/http wraps PHP's messy superglobals and header()/echo output into clean, mockable Request and Response objects. Instantiate once via Sapi::getRequest(), then pass it around with dependency injection, typehinting on RequestInterface. A lightweight alternative to Symfony HttpFoundation, born fro...
Install with Composer and pin a major version — the minimum PHP varies per branch (v6/v7 require PHP 7.4, v5.1 requires 7.1):
composer require sabre/http:^7.0
The mental model is simple: this package replaces direct superglobal and header() access with two objects — Sabre\HTTP\Request and Sabre\HTTP\Response — plus a static Sapi helper for the boundary between PHP and those objects.
Where to look first, in order:
Request and Response API listings in the README (they are the real documentation — there is no separate docs site).Sabre\HTTP\Sapi — two methods matter: getRequest() (builds a Request from $_SERVER, $_GET, $_POST, php://input) and sendResponse() (writes status line, headers via header(), and the body to output).Sabre\HTTP\Client if you need the outbound side.The one-line bootstrap — call each exactly once per application, at the edges:
use Sabre\HTTP;
$request = HTTP\Sapi::getRequest(); // only at the entry point
// ... pass $request around, typehint on HTTP\RequestInterface
HTTP\Sapi::sendResponse($response); // only at the very end
First use case to pick up: a reverse proxy or a legacy-script front controller. It's the shortest path from zero to something working, because it exercises both directions of the library at once:
$request = HTTP\Sapi::getRequest();
$subRequest = clone $request;
$subRequest->removeHeader('Host');
$subRequest->setUrl('http://example.org' . $request->getPath());
$response = (new HTTP\Client())->send($subRequest);
HTTP\Sapi::sendResponse($response);
In a Laravel codebase, don't reach for this to replace Illuminate\Http\Request — you'd be trading a mature, well-integrated component for a smaller one. Reach for it when you need a framework-agnostic request/response layer in a package, a WebDAV/CalDAV client (it's the substrate for sabre/dav), or a lightweight outbound HTTP client with retry hooks.
Typehint on the interfaces, never the concrete classes. Every consumer should accept RequestInterface / ResponseInterface. This is what makes the decorator pattern and mocking work.
Decorate instead of extending. The README is explicit about this: subclassing Request forces you to override the Sapi::getRequest() factory and makes your code harder to compose. Use RequestDecorator / ResponseDecorator:
class MyRequest extends HTTP\RequestDecorator
{
public function isLoggedIn(): bool { return true; }
}
$request = new MyRequest(HTTP\Sapi::getRequest());
Because RequestDecorator proxies every interface method, the rest of your code — including third-party code expecting a plain RequestInterface — keeps working.
Client retry/auth via events. This is the library's strongest feature. The error event fires for any status > 399, and the &$retry flag is passed by reference so you can opt into a retry:
$client->on('error:401', function ($request, $response, &$retry, $retryCount) {
if ($retryCount >= 1) {
return; // don't loop forever
}
$request->setHeader('Authorization', 'Basic ' . base64_encode("$u:$p"));
$retry = true;
});
Use beforeRequest for header injection/correlation IDs and afterRequest for logging or response rewriting.
Async fan-out for parallel I/O. sendAsync() wraps cURL's multi handler. Queue everything, then wait():
$client = new HTTP\Client();
foreach ($urls as $url) {
$client->sendAsync(
new HTTP\Request('GET', $url),
fn(HTTP\ResponseInterface $r) => $store->put($r),
fn($e) => $log->error($e)
);
}
$client->wait();
Middleware chain around the interfaces. Since RequestInterface is a clean contract, a PSR-15-ish pipeline is trivial: each stage takes a request/response and returns a response. This is the cleanest way to add auth, CORS, or path rewriting without touching the core objects.
Adapter at the Laravel boundary. If you must bridge into a Laravel app, write a small converter rather than mixing layers:
function fromIlluminate(Illuminate\Http\Request $r): HTTP\RequestInterface
{
$req = new HTTP\Request(
$r->getMethod(),
$r->fullUrl(),
array_map(fn($v) => $v[0], $r->headers->all()),
$r->getContent()
);
$req->setPostData($r->request->all());
$req->setRawServerData($r->server->all());
return $req;
}
Streams for large payloads. Both Request and Response accept a stream resource for the body. Pass the resource, don't read it into a string, and sendResponse() will stream it out.
Testing. Construct Request/Response directly in tests; never call Sapi::getRequest() in a unit test. Mock RequestInterface with PHPUnit/Mockery — it's a plain interface with no static coupling.
getBodyAsString() may only work once. If the body is a non-rewindable stream, the second call returns empty. Use getBody() when you need to know whether you have a string or a resource, and rewind explicitly when you know it's seekable. This bites hardest in async callbacks where you log the response and then try to read it again.
$_POST is a lie for JSON bodies. PHP only populates $_POST for application/x-www-form-urlencoded and multipart/form-data. For JSON APIs, getPostData() is empty and you must read getBodyAsString() and json_decode yourself. setPostData() exists precisely because php://input can't be relied on for form data.
Sapi::getRequest() is only meaningful in a web SAPI. In CLI, Artisan commands, and queue workers, the superglobals are junk. Construct a Request manually instead. Likewise Sapi::sendResponse() calls header() — it fails with "headers already sent" if anything has echoed, and does nothing useful in CLI.
Base URL and getPath(). getPath() computes the path relative to the base URL and throws LogicException if the request is outside it. If your app lives in a subdirectory or behind a proxy that rewrites prefixes, call setBaseUrl() correctly or every request blows up. Also note getPath() URL-decodes and converts ISO-8859-1 to UTF-8 — don't double-decode.
Headers are case-insensitive to read, case-preserving to write. getHeader('content-type') works; setHeader('Content-Type', ...) keeps your casing. Don't use array_key_exists on getHeaders() output — normalize keys first.
setStatus() accepts int or a full string. Passing 403 gives you "403 Forbidden"; passing "403 I can't let you do that, Dave" uses your custom reason phrase. Great for proxy pass-through, surprising if you assumed a numeric-only API.
Client is not Guzzle. No PSR-18/PSR-7 compliance, no middleware stack, no connection pooling beyond cURL multi, no built-in cookie jar. If you need interoperability with Laravel's Http facade or Guzzle middleware, write an adapter. The sabre/event dependency is required for the Client's event methods.
The async example has a subtle footgun. Reusing a single $request across many sendAsync() calls shares the same body stream and header array across all of them. Build a fresh Request per call when bodies differ.
Version/HTTP details. setHttpVersion() expects '1.0', '1.1', or '2.0' — not 2. sendResponse() faithfully emits the version string.
Extension points, ranked by usefulness: decorators (safe, composable) → Client event listeners (powerful, by-reference retry) → subclassing Client and overriding send() (last resort; Sapi is static and hard to test, so wrap it behind your own small service if you need to mock it).
Strategic note. With ~190 stars and a single-maintainer cadence, treat this as a focused utility, not infrastructure you bet the product on. If you're choosing a request/response abstraction for a general Laravel app, symfony/http-foundation (which Laravel already ships) or Guzzle is the lower-risk pick. sabre/http earns its place specifically in the sabre/* ecosystem, in WebDAV/CalDAV integrations, and in small framework-agnostic packages where its decorator pattern and event-driven retry are genuinely nicer than the alternatives.
How can I help you explore Laravel packages today?