windwalker/http
Windwalker HTTP is a PHP library for working with HTTP requests and responses, offering request/response objects, URI handling, input filtering, file uploads, and middleware for building and inspecting web application traffic with a clean, fluent API.
Install
composer require windwalker/http
Two caveats before you start:
ventoviro/windwalker-http) is a fork/vendor mirror. The package name you actually require is windwalker/http; if you pin the fork you may lag the mainline by several releases.composer.json for the PHP constraint on the tag you pull — 2016-era Windwalker targets PHP 5.5+/7.0 and predates the PSR-15/PSR-17 ecosystem.Where to look first
| Path | What lives there |
|---|---|
src/Request/Request.php |
createFromGlobals(), superglobal-backed request object |
src/Response/ |
Response, JsonResponse, HtmlResponse, TextResponse, RedirectResponse |
src/Uri/ |
URI parsing/building, query-string handling |
src/Stream/ |
Stream, StringStream, PhpInputStream |
src/Transport/ |
Client-side senders (CurlTransport, SocketTransport, StreamTransport) |
src/Output/ |
The piece that actually pushes headers + body to the SAPI |
Read Request and Response end-to-end before anything else. The rest of the package is plumbing around those two objects.
First use case — a front controller
use Windwalker\Http\Request\Request;
use Windwalker\Http\Response\HtmlResponse;
use Windwalker\Http\Output\Output;
$request = Request::createFromGlobals();
$response = new HtmlResponse('<h1>Hello</h1>');
(new Output())->respond($response);
That's the whole mental model: build a request from globals, produce a response object, hand it to Output. Everything else in the package exists to make those three steps testable, composable, and reusable on the client side.
The main win over raw $_GET/$_POST is that the request object can be constructed in tests without touching globals.
$path = $request->getUri()->getPath();
$method = $request->getMethod();
switch (true) {
case $path === '/api/users' && $method === 'GET':
return $this->listUsers($request);
case $path === '/api/users' && $method === 'POST':
return $this->createUser($request);
default:
return new TextResponse('Not Found', 404);
}
Windwalker's input layer (in the 2.x line, Windwalker\Http\Input; in 3.x, getQuery()/getPost() on the request) gives you casted reads with defaults. Use the casted variants rather than casting by hand at every call site:
$page = $input->getInt('page', 1);
$search = $input->getString('q', '');
$filters = $input->getArray('filter', []);
Cast-at-the-boundary means your controller code never has to ask "is this a string or an int today?"
use Windwalker\Http\Response\JsonResponse;
return new JsonResponse([
'data' => $users,
'total' => $total,
]);
JsonResponse sets Content-Type: application/json for you and handles encoding. If you need control over encoding flags (unicode escaping, pretty-print in debug), pass them through rather than post-processing the body — the response owns its own serialization.
use Windwalker\Http\Response\RedirectResponse;
return new RedirectResponse('/login', 302);
For error handling, prefer returning a response over header()/exit — the moment you call exit inside a controller you've broken the testability the package was bought for.
This is the underrated half of the package. Transport lets you reuse the same Request/Response types for outbound calls:
use Windwalker\Http\Transport\CurlTransport;
use Windwalker\Http\Request\Request as HttpRequest;
$transport = new CurlTransport();
$response = $transport->request(
(new HttpRequest('https://api.example.com/users'))
->withMethod('GET')
->withHeader('Accept', 'application/json')
);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
The symmetry matters: you can unit-test the outbound layer by swapping in a stub transport that returns a canned Response.
Never let createFromGlobals() into a test. Construct the request directly:
$request = (new Request())
->withMethod('POST')
->withUri(new Uri('/api/users'))
->withHeader('Content-Type', 'application/json');
$response = $controller->handle($request);
$this->assertSame(201, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
If createFromGlobals() is baked into your bootstrap, refactor so the front controller accepts an injected Request. That single change is what makes the package worth keeping.
If you're migrating off Windwalker, put an adapter at the edge rather than rewriting handlers:
// PSR-7 in, Windwalker in the middle, PSR-7 out
$psrResponse = $psr17Factory->createResponse($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
$psrResponse = $psrResponse->withHeader($name, $values);
}
$psrResponse->getBody()->write((string) $response->getBody());
Keep the adapter thin and stateless so you can delete it later without touching business logic.
Mutability is version-dependent. Windwalker 2.x used setters (setStatus(), setHeader()) that mutate in place; the 3.x line moved toward PSR-7-style immutable with*() methods that return clones. Mixing the two idioms is the #1 source of "my header didn't get set" bugs — assigning $response->withStatus(404); without capturing the return value silently does nothing. Pick one idiom per file and check the return type in the source if you're unsure.
createFromGlobals() is a trap in disguise. It reads $_GET/$_POST/$_FILES/$_SERVER at construction time. Under CLI, PHP-FPM behind a reverse proxy, or a test harness, those are empty or wrong. Also watch X-Forwarded-* — the package will not trust or parse them for you, so getUri()->getScheme() and getClientIp()-style helpers will report the proxy, not the client.
Header lookup is case-insensitive; header storage may not be. Always read through getHeaderLine($name) rather than indexing the array from getHeaders(). If you index directly, content-type and Content-Type will look like different keys depending on how they were set.
Streams and the read cursor. (string) $response->getBody() on some stream implementations reads from the current position — if a transport or middleware already consumed it, you get an empty string. If you see truncated bodies, rewind() first or prefer StringStream for anything you build yourself.
Output assumes it owns the SAPI. It will call header() and echo. If you've already emitted output (a stray var_dump, a BOM in a config file, a closing ?> with trailing whitespace), headers-already-sent warnings follow. Keep Output as the very last statement and never echo in a handler that returns a response.
JSON encoding failures are quiet on old PHP. json_encode on invalid UTF-8 returns false, and a naive JsonResponse will happily send an empty body with a 200. Validate/sanitize input encoding, and in debug mode assert that the encoded body is non-empty.
File uploads need explicit error inspection. $_FILES entries have an error code that is 0 for success; the UploadedFile wrapper exposes it, but nothing throws for you. Check getError() before calling moveTo()/move(), and remember that a UPLOAD_ERR_OK entry can still be a partial write on some configurations.
Query strings with arrays are lossy. ?filter[a]=1 round-trips through parse_str/http_build_query, which mangles keys containing dots or spaces (they become underscores). If you accept arbitrary filter names, encode as JSON in a single query param instead.
Debugging recipe. When a request looks wrong, dump in this order: getMethod(), getUri() (__toString()), getHeaders(), then the raw body string. Nine times out of ten the problem is that the URI was constructed without the base path, or the body was consumed by a middleware earlier in the chain.
Extension points worth knowing:
Response (or the abstract base) to add project-wide defaults like security headers — do it in one place, not in every handler.Transport for retry logic, logging, or a mock in tests; it's a small interface and the cleanest seam in the whole package.Output if you need to buffer or post-process the final payload (e.g. gzip, or capturing output in an integration test).Long-term advice. With 6 stars, 0 dependents, and no release since 2016, this package will not receive compatibility fixes for modern PHP (8.x typed properties, deprecations around dynamic properties and strlen(null)). Budget for a migration to nyholm/psr7 + laminas/laminas-diactoros (or Symfony HttpFoundation if you're not PSR-minded) rather than planning new features on top of it. The Transport abstraction is the piece most worth porting conceptually; the URI and header helpers will be replaced by PSR-7 equivalents almost one-for-one.
How can I help you explore Laravel packages today?