psx/http
PSX HTTP is a lightweight PHP HTTP component for handling requests and responses, including PSR-7-style message abstractions and utilities for building HTTP-aware applications. It fits the PSX framework ecosystem and can be used standalone for routing-adjacent request/response work.
psx/http ships only interfaces — there is no runtime you can composer require and call. Treat it as a contract package, not a library. The entire "documentation" is the src/ tree, so step one is to read it:
composer require psx/http
ls vendor/psx/http/src
You will find roughly three groups of contracts:
MessageInterface, RequestInterface, ResponseInterface, StreamInterface, HeadersInterface, UriInterface, EnvironmentInterfaceMiddlewareInterface, StackInterface (a middleware receives both a request and a response plus a $next callable)ClientInterface, which turns a RequestInterface into a ResponseInterfaceLook at the interfaces first, not at the psx framework packages. The psx framework (psx/api, psx/framework) is what actually implements these contracts, and it is a full-stack framework you are not going to bolt onto Laravel. The realistic value for a Laravel developer is the opposite direction: implement these interfaces as thin adapters around Laravel's HTTP layer so psx-shaped code (third-party middleware, an API SDK, a legacy psx controller) can run inside a Laravel route.
First use case, a minimal inbound adapter:
namespace App\Http\Psx;
use Illuminate\Http\Request as LaravelRequest;
use PSX\Http\RequestInterface;
use Psr\Http\Message\StreamInterface;
final class RequestAdapter implements RequestInterface
{
public function __construct(private LaravelRequest $request) {}
// implement each interface method by delegating:
// getMethod() => $this->request->getMethod()
// getUri() => UriAdapter::fromSymfony($this->request->getUri())
// getHeaders() => $this->request->headers->all()
// getBody() => StreamAdapter::fromString($this->request->getContent())
}
Bind it once and the rest of your code can type-hint RequestInterface without ever importing Illuminate\Http\Request:
// app/Providers/PsxServiceProvider.php
$this->app->bind(RequestInterface::class, fn ($app) =>
new RequestAdapter($app['request'])
);
Verify method signatures against vendor/psx/http/src before writing the adapter — the contracts are small, and a few (especially around header casing and stream handling) differ from what PSR-7 habits would suggest.
Inbound adapter + route macro. The highest-value pattern is mounting psx-style middleware behind a single Laravel route. Build the psx request once, run the stack, and convert the resulting ResponseInterface back into something Laravel can return:
Route::any('/legacy/{any?}', function (RequestInterface $psxRequest) {
$response = (new Stack($middleware))->handle($psxRequest);
return response(
(string) $response->getBody(),
$response->getStatusCode(),
$response->getHeaders()
);
})->where('any', '.*');
The conversion layer (ResponseAdapter::toLaravel()) is where you centralize header normalization and body streaming — keep it boring and tested.
Bidirectional middleware bridge. psx middleware sees request and response, Laravel middleware sees only the request. Don't try to unify the two pipelines. Instead, wrap psx middleware as a Laravel middleware that owns the response object:
final class RunPsxStack
{
public function __construct(private array $stack) {}
public function handle($request, Closure $next)
{
$response = $next($request); // let Laravel finish first
$psxResponse = ResponseAdapter::fromLaravel($response);
$psxResponse = (new Stack($this->stack))
->handle(RequestAdapter::fromLaravel($request), $psxResponse);
return ResponseAdapter::toLaravel($psxResponse);
}
}
This "post-process" shape matches how psx middleware actually behaves (it decorates a response) and avoids re-implementing Laravel's pipeline.
Outbound client abstraction. If you want to depend on ClientInterface rather than the Http facade (useful for swapping to a different transport or for fake-based tests), implement it over the facade:
final class LaravelHttpClient implements ClientInterface
{
public function request(RequestInterface $request): ResponseInterface
{
$response = Http::withHeaders($request->getHeaders())
->send($request->getMethod(), (string) $request->getUri(), [
'body' => (string) $request->getBody(),
]);
return new ResponseAdapter($response->status(), $response->headers(), $response->body());
}
}
Bind the interface in a provider, and mock the interface in tests instead of faking the facade.
Fakes over mocks for unit tests. Because these are narrow interfaces, a hand-written FakeResponse implements ResponseInterface is often faster and more readable than a PHPUnit mock, and it survives interface additions better than a mock with a dozen ->method() calls.
Decorator layering. The interfaces compose cleanly: a caching client decorator, a logging middleware, a header-normalizing message decorator. This is the one place psx/http genuinely shines — the contracts are small enough that a decorator is ~30 lines.
Keep the adapter layer thin and in one namespace. App\Http\Psx\{RequestAdapter, ResponseAdapter, UriAdapter, StreamAdapter}. Everything else in your app should speak Laravel. If PSX\ imports start leaking into controllers, the abstraction is costing you more than it returns.
Psr\Http\Message inheritance and no interoperability with Guzzle, Symfony HttpClient, or Laravel's own PSR bridges. You cannot hand a psx/http request to a package expecting PSR-7. Plan to convert at the boundary, once, in the adapter.composer require gives you zero classes your app can instantiate. If class_exists() fails on something you expected, you are looking for an implementation in psx/framework / psx/api, which pulls in a much larger dependency graph. For a Laravel app, writing your own adapters is usually cheaper.setHeader, setBody) rather than PSR-7's withHeader. Code written from PSR-7 muscle memory will silently mutate shared instances. If you need immutability, implement it in your adapter (clone-on-write) and document it.(request, response, next) — the response is passed in, not returned by $next. Anyone porting Laravel middleware will get this backwards and produce a doubled body or a lost status code. Sketch the flow on paper before coding.getBody() twice (once for logging, once for the response) yields an empty body. Buffer to a string in the adapter, or rewind explicitly if the stream reports seekable. This is the single most common cause of "the JSON response is empty in one environment only."PSX\Http\Exception family) rather than returning a 4xx response. In Laravel those become 500s unless you catch and map them in Handler::render or in your adapter. Add a mapping before you ship.HeadersAdapter and write one test per edge case (multi-value Set-Cookie, empty header, numeric header name).var_dump hostile. A dd() on a request implementation can dump megabytes of stream and environment data. Log a deliberate context array (method, uri, header names, body length) rather than the object.composer.json, read the changelog on every bump, and do not assume semver discipline for the interfaces — an added method is a breaking change for your adapter.App\Http\Psx\ExtendedRequestInterface extends RequestInterface) rather than forking the package. Your adapters implement the extended contract; psx code still sees the base one.php-http/guzzle7-adapter gives you far more ecosystem for the same effort. Reach for psx/http only when you are explicitly interoperating with psx-based code or artifacts.How can I help you explore Laravel packages today?