Includes guidance on reference implementations (nyholm/psr7, guzzlehttp/psr7, laminas-diactoros) and how to choose between them, plus a complete middleware-pipeline template.
Do NOT use this skill for Laravel's own HTTP layer (route to laravel-expert instead) or for Symfony's HttpFoundation component, which is a different, non-PSR-7 HTTP model — bridge the two via symfony/psr-http-message-bridge when needed (see references/implementations.md).
PHP HTTP — PSR-7 / 15 / 17 / 18
Agent Workflow (MANDATORY)
Before ANY implementation, spawn 3 agents in parallel, one Agent call each with a name:
- fuse-ai-pilot:explore-codebase - Detect the PSR-7 implementation already in use (composer.json)
- fuse-ai-pilot:research-expert - Verify current interface versions on php-fig.org + Packagist
- mcp__context7__query-docs - Check the chosen implementation's factory API
After implementation, run fuse-ai-pilot:sniper for validation.
Overview
These four PSR standards let libraries handle HTTP without coupling to any framework. Code depends on interfaces (Psr\Http\*); a concrete implementation is injected.
| Standard | Package | Provides |
|---|---|---|
| PSR-7 | psr/http-message |
Immutable HTTP messages: MessageInterface, RequestInterface, ResponseInterface, ServerRequestInterface, StreamInterface, UriInterface, UploadedFileInterface |
| PSR-15 | psr/http-server-handler + psr/http-server-middleware |
RequestHandlerInterface, MiddlewareInterface — the middleware pipeline |
| PSR-17 | psr/http-factory |
Factories that create PSR-7 objects without naming a concrete class |
| PSR-18 | psr/http-client |
ClientInterface::sendRequest() — send a PSR-7 request, get a PSR-7 response |
Critical Rules
- Messages are immutable - Every
with*()returns a NEW instance; the original is unchanged.$r->withHeader(...)alone is a no-op — reassign. - Depend on interfaces, not classes - Type-hint
ResponseInterface, inject aResponseFactoryInterface. NEVERnew Response()inside reusable code. - Streams are NOT immutable -
StreamInterfacewraps a real resource; use read-only streams for requests/responses. - PSR-18 4xx/5xx are NOT errors - A client MUST return them as normal responses; it throws only on transport failure (
NetworkExceptionInterface) or malformed requests (RequestExceptionInterface). - Headers are case-insensitive -
getHeaderLine('foo')==getHeaderLine('FOO'); original case is preserved ingetHeaders().
Architecture
src/
├── Http/
│ ├── Middleware/ # implements Psr\Http\Server\MiddlewareInterface
│ │ ├── ErrorHandlerMiddleware.php
│ │ └── AuthMiddleware.php
│ ├── Handler/ # implements Psr\Http\Server\RequestHandlerInterface
│ │ └── Dispatcher.php # the pipeline runner
│ └── Client/ # wraps a Psr\Http\Client\ClientInterface
└── interfaces/ # your own contracts (SOLID)
→ See middleware-pipeline.md for a complete runnable pipeline
Reference Guide
Concepts
| Topic | Reference | When to Consult |
|---|---|---|
| Messages | psr7-messages.md | Reading/building requests, responses, streams, URIs |
| Middleware | psr15-middleware.md | Building a middleware + handler pipeline |
| Factories | psr17-factories.md | Creating PSR-7 objects implementation-agnostically |
| HTTP Client | psr18-client.md | Sending outbound requests, exception handling |
| Implementations | implementations.md | Choosing nyholm / guzzle / laminas + PSR-18 clients |
Templates
| Template | When to Use |
|---|---|
| middleware-pipeline.md | Building a PSR-15 dispatcher with middleware queue |
Quick Reference
Immutable update (reassign!)
$response = $response
->withStatus(201)
->withHeader('Content-Type', 'application/json');
Minimal middleware
final class AuthMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (!$request->hasHeader('Authorization')) {
return $this->responseFactory->createResponse(401);
}
return $handler->handle($request);
}
}
PSR-17 factory (no concrete class named)
$request = $requestFactory->createRequest('GET', 'https://api.example.com');
$request = $request->withBody($streamFactory->createStream('{"ping":true}'));
→ See psr17-factories.md for all six factory interfaces
Best Practices
DO
- Inject a
ResponseFactoryInterfaceso middleware never names a concrete class - Put an exception-catching middleware FIRST in the queue (PSR-15 recommendation)
- Use
nyholm/psr7when you want a lightweight, strict PSR-7 + PSR-17 in one package - Reassign the result of every
with*()call
DON'T
- Mutate a message in place —
with*()returns a new object - Treat a 404/500 response from a PSR-18 client as an exception
- Confuse Symfony
HttpFoundationwith PSR-7 — they are different; bridge viasymfony/psr-http-message-bridge - Hardcode
new GuzzleHttp\Psr7\Response()in library code — depend on the factory